Skip to content
OpenSouk

Execute owner intents

Poll what your owner confirmed, act on it, record the outcome

An intent is a row we record on your owner's behalf after they confirm something in their linked Telegram chat. It names a product to buy or a review to publish, it carries the payload your tools need, and it sits in a queue until your agent picks it up.

Nothing about it is on-chain, and nothing about it is enforced. The queue records and serves; your agent decides, spends and signs. An intent is data, not authorisation.

Nothing is deployed on a public chain yet — see Quickstart.

What you will build

A loop: poll the queue, evaluate each intent, execute it with the tools you already use, close it.

What an intent is

Seven fields reach your agent per intent.

FieldWhat it is
intent_idAn opaque UUID string. The handle for both closing calls
chain_idThe chain this intent targets
agent_idThe agent it was queued for
typebuy_product or publish_review, and nothing else
payloadRaw JSON, to hand straight to the executor tool
created_atWhen the owner confirmed it
expires_at48 hours after that, always

Only two types exist, and the list is a CHECK constraint rather than a convention. There is no withdraw type, no transfer type, and no way to add one through the queue. The worst a forged or mistaken intent can do is buy a catalogue product that the owner receives, bounded by the owner's spending limit — never move funds out.

The queue never authorised anything. The owner's confirmation created the row; it is not a signature and no on-chain state changed. Re-validate against live state before acting, because the intent's snapshot of the world is as old as the confirmation.

48 hours, and not configurable. Past expires_at the intent is filtered out at read time, so an aged-out intent disappears from your polls whether or not anything has swept it.

It is scoped to one agent on one chain. The same agent id on two chains is two identities with different owners and different queues; the poll is filtered on both.

Who creates one, and how

Your agent does not create intents and neither does the concierge's model. The two owner keystrokes that queue one are deterministic code paths with no model in them, so a queued intent is always a human act:

The owner typesWhat gets queued
/intent buybuy_product, from the purchase draft staged in that chat
/intent review <draft_id>publish_review, from that review draft
/intent cancelnothing — it discards the staged draft

The draft is claimed atomically in the same statement that reads it, so two deliveries of the same keystroke cannot queue the same purchase twice. What the owner does before that point is Use the concierge.

Two gates run before the row is written: the chat must be linked to a wallet (Link Telegram), and that wallet must be the on-chain owner of the agent the intent names.

Before you start

  • The owner's key in referrer-signer. All three intent commitments recover the wallet ownerOf(agent_id) returns. A signature from a bound agent wallet is refused, even where a contract would accept one — see Identity and commitments.
  • Both MCP servers. The queue tools are referrer-agent; the three signing tools are referrer-signer. Connect via MCP wires both.
  • The executor tools you already use. The queue hands you a payload and nothing more: pay_for_product for a purchase, publish_escrow_review or the admin-approved review path for a publish.
  • A linked owner. An unlinked owner has no way to queue anything, so an empty queue forever is the expected result of skipping that step.

Pass chain_id on every referrer-agent call below: a call that names no chain falls back to the deployment's first configured chain, which the shipped default makes Base Sepolia. Where a payload below elides chain_id, the value is the chain that intent targets. These three tools are MCP only — they have no /v1 twin at api.opensouk.ai.

Prompt mode

Show the prompt
Run my OpenSouk owner intent queue on Base mainnet for agent id 7, once.

Poll:
1. Call get_pending_intents with agent_id and NO agent_sig. It returns a commitment, a nonce
   and an expiry one hour out.
2. Call sign_intent_access_commitment with that commitment object exactly as returned. Do not
   ask me to sign anything — the owner key is in the signer.
3. Call get_pending_intents again with the same agent_id plus nonce, expiry and the signature
   as agent_sig.
4. The intents array is OMITTED, not empty, when there is nothing queued. Treat a missing
   intents key as "no work", not as an error.

Budget, before you spend anything:
5. Read remaining_budget, per_tx_cap_usdc and budget_enforced off that same response. Check
   every buy_product amount against both figures. Nothing on the server does this for you.
6. remaining_budget: null does NOT mean unlimited. When budget_enforced is true and
   remaining_budget is null, only a per-transaction cap was set — check against
   per_tx_cap_usdc alone.

Per intent, oldest first:
7. Re-validate against live state before acting: is the product still active, is the merchant
   still active, has the price moved past ref_terms_snapshot, is the amount within budget?
8. buy_product → pay_for_product, with max_amount taken from the intent's own payload (never a
   ceiling you invented) and idempotency_key set to the intent_id.
9. publish_review → the escrow-proof path when the payload carries escrow_id, or the
   admin-approved path when it carries entry=admin_approved.
10. Executed: call mark_intent_executed with intent_id AND the final result string ALREADY SET
    in step 1 — result is bound into the commitment, so it cannot be added or changed after
    signing. Sign with sign_intent_mark_commitment, then resend with nonce, expiry, agent_sig.
11. Declined: call mark_intent_skipped with intent_id AND a non-empty reason, both in step 1.
    Sign with sign_intent_skip_commitment. reason is required and is signed.
12. marked: false and skipped: false are successes, not errors — the intent was already
    terminal, unknown, or not mine. Do not retry the same intent.

Report each intent_id with what you did and the result or reason you recorded.

Manual mode

Ask for the read commitment

{ "agent_id": 7 }

get_pending_intents with no agent_sig is step 1. It returns status: commitment plus the typed data to sign, a fresh 32-byte nonce as 0x-prefixed hex, and an expiry one hour out.

agent_id is required. Zero is a valid value — ids come from a counter that starts there — so omitting it is not the same as passing it.

Sign it, then resend

Hand the commitment object to sign_intent_access_commitment exactly as it came back. That tool takes the whole object and nothing else; it reads the agent id out of the message to pick the key, so there is no separate agent_id argument.

{ "agent_id": 7, "nonce": "0x6d…", "expiry": 1785, "agent_sig": "0x…" }

The nonce and expiry go back unchanged: the server rebuilds the same commitment from what you echo and compares the recovered signer against ownerOf(agent_id). Change either and the recovered address is not the owner.

This signature authorises a read, and its nonce is not burned. It stays usable for the rest of its hour, so treat a signed one as a short-lived read token rather than as a record of a decision.

Read the queue and the two budget figures

{
  "status": "ok",
  "intents": [
    { "intent_id": "3f9c…", "chain_id": , "agent_id": 7, "type": "buy_product",
      "payload": { "product_id": "0x…", "merchant_id": "…", "max_amount": "1500000",
                   "ref_terms_snapshot": { } },
      "created_at": "2026-09-02T18:41:07Z", "expires_at": "2026-09-04T18:41:07Z" }
  ],
  "remaining_budget": 19500000,
  "per_tx_cap_usdc": 5000000,
  "budget_enforced": true
}

Oldest first, by created_at, and there is no limit or pagination — one response carries the whole pending set.

An empty queue omits intents entirely. The key is absent rather than present as [], so a client that reads response.intents.length throws on the ordinary no-work case.

The three budget fields are always present, null where unset, and are advisory. What each combination means is on Set spending limits.

Re-validate before you act

The payload is a snapshot from the moment the owner confirmed, up to 48 hours ago. Four things to check against live state, whatever the payload says:

  1. The product is still active, and the merchant is. Both rails refuse to settle otherwise — x402 and MPP.
  2. The terms have not drifted past what ref_terms_snapshot recorded. The snapshot is stored verbatim and never re-checked by us.
  3. The amount is within both budget figures.
  4. You have not already executed this intent in an earlier cycle whose mark call failed.

Anything that fails: skip it with a reason, in the step below. Do not execute a modified version of it — a purchase at a different amount is not the thing the owner confirmed.

Execute with the tools you already have

typePayload carriesWhat executes it
buy_productproduct_id, merchant_id, max_amount, ref_terms_snapshotpay_for_productBuy through a ref link
publish_review with escrow_iddraft_id, content_hash, content_json, merchant_id, product_id, entry, escrow_idThe escrow-proof path — Publish with escrow proof
publish_review with entry: "admin_approved"the same, without escrow_idThe admin-approved path — Publish your first review

max_amount is the ceiling the owner confirmed rather than one you invented, and idempotency_key should be the intent_id — which makes a re-executed intent a rejected duplicate rather than a second purchase. Branch on which of escrow_id and entry a publish_review payload carries; nothing else in it tells you which call to make.

Mark it executed — with the result already decided

{ "agent_id": 7, "intent_id": "3f9c…", "result": "escrow:88" }

mark_intent_executed is two-step like the poll, with one difference that decides how you sequence your own code: intent_id and result are both required in step 1, because both are bound into the commitment. Pass the final result — an escrow id, a transaction hash, whatever identifies what happened — before you sign, not after.

Sign with sign_intent_mark_commitment, then resend nonce, expiry and agent_sig alongside the identical intent_id and result.

{ "status": "ok", "marked": true }

result is optional only in that the empty string is accepted. A result in step 2 that differs from the one signed in step 1 recovers a different address and is rejected as not from the owner.

Or decline it — with a reason that is also signed

{ "agent_id": 7, "intent_id": "3f9c…", "reason": "live price above the confirmed cap" }

mark_intent_skipped is the same shape with reason in place of result, and reason is required — non-empty, in both steps, and bound into the commitment, so the reason the owner reads is one nobody could substitute after signing. Sign with sign_intent_skip_commitment.

{ "status": "ok", "skipped": true }

Skipping is the right call for an intent you evaluated and chose not to carry out: over budget, terms drifted, product gone, policy decline. It clears the intent now instead of leaving it to be re-polled every cycle for up to 48 hours.

One asymmetry between the two closing calls. The skip additionally requires the intent to be unexpired, and the mark does not. An intent that aged out mid-execution can still be recorded as executed — which an agent that really did spend the money needs — but can no longer be skipped, and a late skip returns skipped: false.

Verify

Poll again. A closed intent is gone from the next response, because both closing calls move it out of pending and only pending rows are served. That is the only confirmation available: there is no tool that reads one intent by id and none that lists executed or skipped ones.

What each signature binds

Three distinct commitments, one per operation, all recovering the same wallet and all expiring one hour after step 1.

CommitmentBindsWhat a replay inside the hour achieves
IntentAccessCommitmentthe agent idanother read of the queue
IntentMarkCommitmentthe agent id, intent_id, resultnothing — the flip already happened, and a second attempt matches zero rows
IntentSkipCommitmentthe agent id, intent_id, reasonthe same

They share one EIP-712 domain and are kept apart by their type strings, so a read signature can never recover as a mark signer or the reverse. The strings themselves are on Commitment types, and what a commitment does and does not prove is on Identity and commitments.

No nonce is burned on any of the three because all three operations are idempotent: the read changes nothing, and each flip is a single conditional UPDATE that matches zero rows the second time. The hour is the only bound on a leaked triple.

Errors and retries

MessageWhat it means
intent queue not enabledThe queue is not wired on this deployment. Not retryable
chain N not configuredchain_id names a chain this server does not run
agent_id must not be negative: …A negative agent_id. Zero is valid
intent_id is requiredOmitted, in either step, on either closing call
reason is requiredOmitted on the skip, in either step
nonce and expiry are required with agent_sigStep 2 missing an echoed field
commitment expiredPast expiry. Restart at step 1; the hour is not extendable
invalid nonce: must be 0x-prefixed 32-byte hexMalformed nonce. Echo step 1's value verbatim
invalid agent_sig: must be 0x-prefixed 65-byte hexWrong signature length, or not hex
signature not from agent owner: recovered 0x…, want 0x…Not the owner wallet — or a changed intent_id, result or reason
agent N not found on-chain: …ownerOf reverted. Wrong id, or wrong network
get pending intents: …The queue read failed. Transient; retry
mark intent executed: … / mark intent skipped: …The flip failed. Transient; retry the same call
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallbackFrom the signer: no owner key resolves for that agent id

marked: false and skipped: false are successes. They mean zero rows matched: the intent was already terminal, unknown, expired in the skip's case, or belongs to another agent or another chain. One message covers all of those on purpose, so you cannot use the call to probe whether an intent id exists. Log it and move on — retrying cannot change the answer.

One undecodable payload does not fail the poll. That intent is dropped and logged; the rest are served. So a poll can legitimately return fewer intents than the owner queued, and the count is not something to assert on.

A payload of null is possible. An intent stored with an empty payload serves null rather than {}. Guard the field before indexing into it.

What does not happen

Four things the queue does not do, each of which is a thing to build yourself if you need it:

  • Nobody is notified when you close an intent. No message reaches the owner's chat on either call. An intent_status kind exists on the proactive-notification whitelist and nothing emits it, so telling the owner what you did is your loop's job.
  • Nobody enforces the budget. The figures are served and never checked.
  • Nothing pushes work to you. Your agent is a client; the cadence of your polling is the latency the owner experiences, and an intent nobody polls for simply expires.
  • No status is recorded for an intent that aged out. It stops being served and stays as it was. There is no read that distinguishes it from one that was never queued.

Next steps