This specification defines the Agentic Commerce Protocol: a job with escrowed budget, four states (Open → Funded → Submitted → Terminal), and an evaluator who alone may mark the job completed. The client funds the job; the provider submits work; the evaluator attests completion or rejection once submitted (or the evaluator rejects while Funded before submission, or the client rejects while Open, or the job expires and the client is refunded). Optional attestation reason (e.g. hash) on complete/reject enables audit and composition with reputation (e.g. ERC-8004).
Many use cases need only: client locks funds, provider submits work, one attester (evaluator) signals "done" and triggers payment—or client rejects or timeout triggers refund. The Agentic Commerce Protocol specifies that minimal surface so implementations stay small and composable. The evaluator can be the client (e.g. evaluator = client at creation) when there is no third-party attester.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
A job has exactly one of six states:
| State | Meaning |
|---|---|
| Open | Created; budget not yet set or not yet funded. Client may set budget, then fund or reject. |
| Funded | Budget escrowed. Provider may submit work; evaluator may reject. After expiredAt, anyone may trigger refund. |
| Submitted | Provider has submitted work. Only evaluator may complete or reject. After expiredAt, anyone may trigger refund. |
| Completed | Terminal. Escrow released to provider (minus optional platform fee). |
| Rejected | Terminal. Escrow refunded to client. |
| Expired | Terminal. Same as Rejected; escrow refunded to client. |
Allowed transitions:
setBudget(jobId, amount) to agree on price, then client calls fund(jobId, expectedBudget); contract pulls job.budget from client into escrow.reject(jobId, reason?).submit(jobId, deliverable); signals that work has been completed and is ready for evaluation.reject(jobId, reason?); contract refunds client.block.timestamp >= job.expiredAt, anyone (or client) may call claimRefund(jobId); contract sets state to Expired and refunds client.complete(jobId, reason?); contract distributes escrow to provider (and optional fee to treasury).reject(jobId, reason?); contract refunds client.block.timestamp >= job.expiredAt, anyone (or client) may call claimRefund(jobId); contract sets state to Expired and refunds client.No other transitions are valid.
setProvider(jobId, provider) when job was created with no provider, sets budget with setBudget(jobId, amount), funds escrow with fund(jobId, expectedBudget), may reject only when status is Open. Receives refund on Rejected/Expired.setProvider. May call setBudget(jobId, amount) to propose or negotiate a price. Calls submit(jobId, deliverable) when work is done to move the job from Funded to Submitted for evaluation. Receives payment when job is Completed. Does not call complete or reject.complete(jobId, reason?) or reject(jobId, reason?). When status is Funded, the evaluator MAY call reject(jobId, reason?) (before submission). MAY be the client (e.g. evaluator = client) so the client can complete or reject the job without a third party, or MAY be a smart contract that performs arbitrary checks (e.g. verifying a zero‑knowledge proof or aggregating off‑chain signals) before deciding whether to call complete or reject on the job.Each job SHALL have at least:
client, provider, evaluator (addresses). Provider MAY be zero at creation (see Optional provider below).description (string) — set at creation (e.g. job brief, scope reference).budget (uint256)expiredAt (uint256 timestamp)status (Open | Funded | Submitted | Completed | Rejected | Expired)hook (address) — OPTIONAL. External hook contract called before and after core functions (see Hooks below). MAY be address(0) (no hook).Payment SHALL use a single ERC-20 token (global for the contract or specified at creation). Implementations MAY support a per-job token; the specification only requires one token per contract.
Jobs MAY be created without a provider by passing provider = address(0) to createJob. In that case the client SHALL set the provider later via setProvider(jobId, provider) before funding. This supports flows such as bidding or assignment after creation.
job.provider != address(0), or provider == address(0). SHALL set job.provider = provider and SHALL emit an event (e.g. ProviderSet). Implementations MAY allow an operator role to call setProvider in the future; this specification only requires client-only for the minimal protocol.job.provider == address(0) (provider MUST be set before funding) or if job.budget != expectedBudget (front-running protection).client = msg.sender, provider, evaluator, expiredAt, description, and optional hook address. SHALL revert if evaluator is zero or expiredAt is not in the future. Provider MAY be zero; if so, client MUST call setProvider before fund. hook MAY be address(0) (no hook). Returns jobId.job.provider != address(0), or provider == address(0). SHALL set job.provider = provider. optParams (bytes, OPTIONAL) is forwarded to the hook contract if set (see Hooks).job.budget = amount. SHALL revert if job is not Open or caller is not client or provider. optParams forwarded to hook if set.job.provider == address(0)), or job.budget != expectedBudget (front-running protection). SHALL transfer job.budget of the payment token from client to the contract (escrow) and set status to Funded. optParams forwarded to hook if set.deliverable (bytes32) is a reference to submitted work (e.g. hash of off-chain deliverable, IPFS CID, attestation commitment). SHALL emit an event including deliverable (e.g. JobSubmitted). optParams forwarded to hook if set.reason MAY be bytes32(0) or an attestation hash (OPTIONAL). SHALL emit an event including reason if provided. optParams forwarded to hook if set.reason OPTIONAL. SHALL emit an event including reason and the caller (rejector) if provided. optParams forwarded to hook if set.block.timestamp >= expiredAt). SHALL revert if job is not Funded or Submitted, or if the job has not yet expired. SHALL transfer full escrow to client and set status to Expired. MAY restrict caller (e.g. client only) or allow anyone; the specification RECOMMENDS allowing anyone to trigger refund after expiry.reason is an optional attestation commitment (e.g. bytes32 hash of off-chain evidence). Implementations MAY use string and hash it internally. Events SHOULD include reason for indexing and composition with reputation systems. optParams forwarded to hook if set.reason for audit; same treatment as above. optParams forwarded to hook if set.Implementations MAY charge a platform fee (basis points) on Completed, paid to a configurable treasury. The specification does not require a fee. If present, fee SHALL be deducted only on completion (not on refund).
Implementations MAY support an optional hook contract per job to extend the core protocol without modifying it. The hook address is set at job creation (or address(0) for no hook) and stored on the job. A non‑hooked kernel that ignores the hook field (or always sets it to address(0)) is fully compliant with this specification; the reference AgenticCommerce contract follows this minimal pattern, while AgenticCommerceHooked is an extension that layers the hook callbacks on top of the same lifecycle.
A hook contract SHALL implement the IACPHook interface — just two functions:
The selector parameter identifies which core function is being called (e.g. the function selector for fund). The data parameter contains function-specific parameters encoded as bytes (see Data encoding below). The hook uses the selector to route internally:
When a job has a hook set, the core contract SHALL call hook.beforeAction(...) and hook.afterAction(...) around each hookable function:
| Core function | Hookable |
|---|---|
setProvider | Yes |
setBudget | Yes |
fund | Yes |
submit | Yes |
complete | Yes |
reject | Yes |
claimRefund | No — permissionless safety mechanism, SHALL NOT be hookable |
The data parameter passed to hooks contains the core function's parameters encoded as bytes. The encoding per selector:
| Core function | data encoding |
|---|---|
setProvider | abi.encode(address provider, bytes optParams) |
setBudget | abi.encode(uint256 amount, bytes optParams) |
fund | optParams (raw bytes) |
submit | abi.encode(bytes32 deliverable, bytes optParams) |
complete | abi.encode(bytes32 reason, bytes optParams) |
reject | abi.encode(bytes32 reason, bytes optParams) |
optParams field (bytes, OPTIONAL) on each hookable core function is an opaque payload forwarded to the hook via the data parameter. Callers that do not use hooks MAY pass empty bytes. The core contract SHALL NOT interpret optParams; it is for the hook only.beforeAction) are called before the core logic executes. A before hook MAY revert to block the action (e.g. enforce custom validation, allowlists, or preconditions).afterAction) are called after the core logic completes (including state changes and token transfers). An after hook MAY perform side effects (e.g. emit events, update external state, trigger notifications) or revert to roll back the entire transaction.job.hook == address(0), the core contract SHALL skip hook calls and execute normally.expiredAt. This is by design — the hook is part of the job's policy. The guaranteed recovery path is claimRefund after expiry, which is deliberately not hookable so that refunds cannot be blocked.onlyACP modifiers on hooks are RECOMMENDED so that hook functions cannot be called directly by external actors.Implementations MAY provide a BaseACPHook that routes the generic beforeAction/afterAction calls to named virtual functions (e.g. _preFund, _postComplete) so hook developers only override what they need. This is NOT part of the standard — only IACPHook is normative.
Problem: A client hires an agent to convert/bridge/swap tokens (e.g. USDC → DAI). The client provides capital to the provider, who uses it to produce output tokens. The hook must ensure the provider deposits the output tokens before the job completes, then release them to the designated buyer.
Solution: A FundTransferHook that (a) stores a transfer commitment at setBudget, (b) forwards capital to the provider at fund, (c) pulls output tokens from the provider at submit, and (d) releases them to the buyer at complete.
Key properties: (1) The provider cannot submit without depositing output tokens. (2) The buyer only receives tokens when the evaluator completes the job. (3) On rejection or expiry, tokens are returned to the provider.
Problem: A client wants to hire the cheapest (or best) agent for a job but does not know upfront who to assign. The selection should be determined by an open bidding process, not unilaterally by the client after the fact.
Solution: A BiddingHook that verifies off-chain signed bids. Providers sign bid commitments off-chain; the client collects bids, selects the winner, and submits the winning bid's signature via setProvider. The hook's beforeAction callback recovers the signer and verifies it matches the chosen provider — proving the provider actually committed to that price.
Zero direct calls to the hook. All interactions flow through the core contract → hook callbacks.
Key property: The client cannot fabricate a provider commitment. The hook verifies the chosen provider actually signed a bid at the claimed price. The client is incentivised to pick the lowest bidder since they are the one paying.
Implementations SHOULD emit at least:
reason on complete/reject; no additional ledger is required.expiredAt gives client a way to reclaim funds without an explicit reject.IACPHook interface uses just two functions (beforeAction/afterAction) with a selector parameter rather than named functions per action. This keeps the interface stable as the core protocol evolves — new hookable functions simply produce new selector values without changing the interface.The following extensions are OPTIONAL and do not modify the core protocol. Implementations MAY adopt them independently.
Agentic Commerce is intentionally minimal and does not embed a reputation system. For on-chain reputation and trust relationships between agents, implementations are RECOMMENDED to integrate with ERC-8004 (Trustless Agents).
The following patterns are RECOMMENDED:
Outcome‑based trust signals
Completed: positive signal for provider (and optionally evaluator) based on successful delivery.Rejected: negative or neutral signal, depending on the reason and who rejected (client vs evaluator).Expired: neutral or mildly negative signal for client (for not evaluating) or for provider (for not submitting), depending on higher‑level policy.Evaluator attestations
complete(jobId, reason, optParams?) and reject(jobId, reason, optParams?), the evaluator (which MAY be a contract) SHOULD:
reason (e.g. a hash of off‑chain evidence).Completed or Rejected.afterAction for complete/reject, keeping the core ACP contract unaware of the registry details.Reputation‑aware policy via hooks
setProvider from assigning providers below a reputation threshold,beforeAction hooks so they can safely revert and block actions that violate reputation policies.Separation of concerns
To support gasless execution — where a client, provider, or evaluator signs an intent off-chain and a facilitator submits the transaction on their behalf — implementations SHOULD support ERC-2771 (Secure Protocol for Native Meta Transactions).
How it works:
createJob, fund, submit)._msgSender() (from ERC2771Context) instead of msg.sender to identify the caller.Implementation requirements:
ERC2771Context (or equivalent) and use _msgSender() for all authorization checks (client, provider, evaluator)._msgSender() rather than msg.sender.Token approvals: For functions that pull tokens (e.g. fund), the signer SHOULD use ERC-2612 (permit) to approve token spending via signature. The facilitator can then call permit and fund in a single transaction — no on-chain approval tx needed from the signer.
x402 compatibility: This extension enables compatibility with HTTP-native payment protocols such as x402, where an AI agent signs payment intents off-chain and a payment facilitator handles on-chain execution. The agent only needs a private key and tokens — no gas, no RPC management, no chain-specific logic.
No backward compatibility issues found.
TBD
evaluator = client.call{gas: HOOK_GAS_LIMIT}(...)) to bound execution cost and prevent hooks from consuming unbounded gas. The specific limit is left to the implementation as gas costs vary across chains.claimRefund is deliberately not hookable so that refunds after expiry cannot be blocked by a malicious hook.Copyright and related rights waived via CC0.