Skip to content

PULSE-1 — an agent label for EVM calldata

Status: Final · Version 1 · Chain: Robinhood Chain (4663) · Last changed 2026-09-19

PULSE-1 is a 24-byte marker appended to the end of a transaction's calldata. It says: this transaction was sent by an automated agent, this is which agent, and this is the framework that built it.

It needs no contract, no registry call and no permission. It costs the calldata gas of 24 bytes. It works with every wallet, router and dapp on the chain, because standard ABI decoding ignores trailing bytes.


1. Format

24 bytes, appended to the end of the calldata:

 0                16        18      19       20            24
 +----------------+---------+-------+--------+-------------+
 |    agentId     |framework| flags |version |    magic    |
 |    16 bytes    | 2 bytes |1 byte | 1 byte |   4 bytes   |
 +----------------+---------+-------+--------+-------------+
FieldSizeValue
agentId16Identifies the agent. Any 16 bytes are valid.
framework2Big-endian code from the framework registry. 0x0000 = unknown/custom.
flags1Bit 0 autonomous, bit 1 test, bits 2–7 reserved and must be zero.
version10x01 for this document.
magic40x50554C53 — ASCII PULS. Always the last four bytes of the calldata.

A reader checks calldata[-4:] == 0x50554C53 and parses backwards. A label is well-formed only when the calldata is at least 24 bytes long, the version is a version it knows, and the reserved flag bits are zero. Anything else is not a PULSE-1 label and is ignored.

1.1 agentId

Recommended derivation, so that two operators never collide by accident and an operator can recreate an ID from a name:

agentId = keccak256(abi.encodePacked(operatorAddress, agentName))[0:16]

operatorAddress is the 20-byte address that will claim the ID on Pulse. agentName is UTF-8, not normalised, compared byte for byte.

Any other 16 bytes are valid. The derivation is a convention, not a rule: the chain cannot check it, so nothing on Pulse depends on it.

1.2 flags

BitNameMeaning
0autonomousNo human approved this specific transaction.
1testTest traffic. Excluded from every public number. Visible to the operator only.
2–7reservedMust be zero. A label with a non-zero reserved bit is not well-formed.

test is load-bearing: it is how an integrator runs against mainnet without polluting the public scoreboard. Set it in every non-production environment.

1.3 version

0x01. A reader that does not know a version ignores the label rather than guessing. New versions keep the magic in the last four bytes and the version byte immediately before it, so any reader can always find the version.


2. Framework codes

Two bytes, big-endian, from config/frameworks.json:

CodeFramework
0x0000Unknown / custom
0x0001Pulse MCP server
0x0002Pulse SDK (TypeScript)
0x0003Pulse SDK (Python)
0x0010First external adopter

Codes 0x00110xFFFF are assigned by pull request against config/frameworks.json. A pull request gives the framework a name, a public URL and a contact. Codes are never reassigned, even when a framework is retired; a retired row stays and gains "retired": true. Using 0x0000 is always allowed and never wrong.


3. Where the label goes

3.1 Direct transactions from an EOA

Append to tx.data. A plain ETH transfer carries the 24 bytes as its entire data:

to:    0xRecipient
value: 1 ether
data:  0x<agentId><framework><flags><version>50554c53

The recipient must be an EOA or a contract whose receive() tolerates data — see §4.

3.2 ERC-4337

Append to the inner callData of the UserOperation, never to the handleOps calldata the bundler builds. Indexers decode EntryPoint.handleOps and check each op's callData tail.

Robinhood Chain has EntryPoint v0.6, v0.7 and v0.8 deployed; v0.7 and v0.8 are in active use.

3.3 Batches and multicall

Append once, to the outermost call. One transaction is one agent action; labelling the inner calls would double-count it.


4. Safety — the label must never break a transaction

This is the rule that matters most. An agent that loses a transaction to a label will remove the label, and rightly.

4.1 Default-safe call types

Trailing calldata is inert for a call when the target ABI-decodes its arguments by offset and ignores the remainder. That is true for:

  • Native transfers to an EOA.
  • ERC-20 transfer, approve, transferFrom.
  • Uniswap v2 router, v3 SwapRouter02, and Universal Router calls.
  • Permit2 calls.
  • WETH deposit / withdraw.
  • ERC-4337 inner callData.
  • Any address marked "tagSafe": true in config/labels.json.

4.2 Never append when

  1. The target has no code and the caller is sending data on purpose. The label would be indistinguishable from the intended payload.
  2. The target is "tagSafe": false in config/labels.json — including the EntryPoints, where the label belongs on the inner call.
  3. The calldata is already labelled. Never stack two labels.
  4. The function packs arguments manually rather than ABI-decoding them — anything that reads msg.data.length, uses calldatacopy over the tail, or verifies a signature over the whole calldata. Permit2's signature covers a struct, not the calldata, so Permit2 is safe; a contract that signs over msg.data is not.
  5. **A simulation of the labelled call reverts while the unlabelled call succeeds.** This is the backstop that catches everything the list above misses.

4.3 The fallback is always to send unlabelled

An implementation simulates the labelled call before sending (default on). If the labelled call would revert and the unlabelled one would not, it sends unlabelled and warns. Losing a row on a scoreboard is a small cost; losing a transaction is not. No implementation may make this behaviour non-default.

4.4 The conformance test

Every implementation ships a fork test that proves, for each default-safe call type, that the labelled call and the unlabelled call produce the same state: same balances, same events, same status. A release that has not run it is not a release.


5. Reading labels

1. data = tx.input
2. if len(data) < 24: no label
3. if data[-4:] != 0x50554C53: no label
4. version = data[-5]; if version != 0x01: unknown version, ignore
5. flags = data[-6]; if flags & 0b11111100: malformed, ignore
6. framework = uint16be(data[-8:-6])
7. agentId = data[-24:-8]

For a transaction to an EntryPoint, run the same steps over each userOp.callData in the decoded handleOps call instead.

5.1 The 24-byte collision question

Ordinary calldata ends with 0x50554C53 by accident roughly once in 4.3 billion trailing words. In practice the last word of an ABI-encoded call is a padded argument, so the accidental rate observed on Robinhood Chain is zero: a 300-block sample of 3,512 transactions contained no accidental magic. Readers should nonetheless treat the label as a claim, never as proof — see §6.


6. What a label proves, and what it does not

A label is self-declared. Anyone can write any agentId into their own calldata. PULSE-1 does not try to prevent that; it makes it useless:

  • A transaction counts toward a claimed agent only if its **sender is on that agent's declared sender list**. Copying someone's ID produces an unclaimed row, never a row under their name.
  • Unclaimed labels still count in chain totals, and the totals always show the claimed/unclaimed split.
  • Staking behind an ID (planned) makes a verified label cost money to fake, and lose money when caught.

Read the label as "this sender says it is an agent". That claim is cheap, and it is still far more information than the chain gives you otherwise.


7. Worked example

Operator 0x1111111111111111111111111111111111111111, agent named price-watcher, built on the Pulse TypeScript SDK, running autonomously in production:

agentId   = keccak256(0x1111...1111 ‖ "price-watcher")[0:16]
          = 0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3   (illustrative)
framework = 0x0002
flags     = 0x01            (autonomous, not test)
version   = 0x01
magic     = 0x50554c53

label     = 0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3 0002 01 01 50554c53

Sending 5 USDG becomes:

data = 0xa9059cbb
         000000000000000000000000cafe...beef      <- recipient
         00000000000000000000000000000000004c4b40 <- 5_000000
         9f2a0c1e7b5d4a8f36c20e91d7b4a5c3000201015 0554c53

The ERC-20 decodes its two arguments and never looks past them.


8. Changelog

  • v1 (2026-09-19) — first published version.

Changes that alter the byte layout get a new version byte and a new section here. The magic and the position of the version byte never move.