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.
| Field | What it is |
|---|---|
intent_id | An opaque UUID string. The handle for both closing calls |
chain_id | The chain this intent targets |
agent_id | The agent it was queued for |
type | buy_product or publish_review, and nothing else |
payload | Raw JSON, to hand straight to the executor tool |
created_at | When the owner confirmed it |
expires_at | 48 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 types | What gets queued |
|---|---|
/intent buy | buy_product, from the purchase draft staged in that chat |
/intent review <draft_id> | publish_review, from that review draft |
/intent cancel | nothing — 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 walletownerOf(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 arereferrer-signer. Connect via MCP wires both. - The executor tools you already use. The queue hands you a payload and nothing more:
pay_for_productfor a purchase,publish_escrow_reviewor 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:
- The product is still active, and the merchant is. Both rails refuse to settle otherwise — x402 and MPP.
- The terms have not drifted past what
ref_terms_snapshotrecorded. The snapshot is stored verbatim and never re-checked by us. - The amount is within both budget figures.
- 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
type | Payload carries | What executes it |
|---|---|---|
buy_product | product_id, merchant_id, max_amount, ref_terms_snapshot | pay_for_product — Buy through a ref link |
publish_review with escrow_id | draft_id, content_hash, content_json, merchant_id, product_id, entry, escrow_id | The escrow-proof path — Publish with escrow proof |
publish_review with entry: "admin_approved" | the same, without escrow_id | The 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.
| Commitment | Binds | What a replay inside the hour achieves |
|---|---|---|
IntentAccessCommitment | the agent id | another read of the queue |
IntentMarkCommitment | the agent id, intent_id, result | nothing — the flip already happened, and a second attempt matches zero rows |
IntentSkipCommitment | the agent id, intent_id, reason | the 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
| Message | What it means |
|---|---|
intent queue not enabled | The queue is not wired on this deployment. Not retryable |
chain N not configured | chain_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 required | Omitted, in either step, on either closing call |
reason is required | Omitted on the skip, in either step |
nonce and expiry are required with agent_sig | Step 2 missing an echoed field |
commitment expired | Past expiry. Restart at step 1; the hour is not extendable |
invalid nonce: must be 0x-prefixed 32-byte hex | Malformed nonce. Echo step 1's value verbatim |
invalid agent_sig: must be 0x-prefixed 65-byte hex | Wrong 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 fallback | From 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_statuskind 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
- Set spending limits — the three figures this loop reads, and what counts against them
- Telegram and intent tools — all three tools, field by field
- Signer tools — the three signing tools, and what each message carries