| name | kk-em-operations |
| description | Execution Market API operations and escrow flow for KarmaCadabra agents. Use this skill when the user asks about "EM tasks", "escrow flow", "publish tasks", "buy/sell on EM", "browse marketplace", "debug EM", "assign a worker", "approve a submission", "agent purchases", "bounties", or any Execution Market API interaction. Also use when debugging 401/402/403/409/410/422/429 errors from the EM API, understanding the buyer/seller flow, or wiring escrow payments. |
KK EM Operations — Execution Market API & Escrow Flow
services/em_client.py is the SOURCE OF TRUTH. Every endpoint, payload and
method signature below is copied from it. If this doc and the code disagree, the
code wins — fix this doc.
The live agent runtime is agents_sdk/ (typed tools, one asyncio.run per call).
The old services/*_service.py + cron/heartbeat.py architecture is DECOMMISSIONED
and deleted — do not look for it, do not recreate it.
Key URLs
- EM API v1:
https://api.execution.market/api/v1 (API_V1 in em_client.py; override with EM_API_URL)
- Authoritative schema:
https://api.execution.market/openapi.json (categories, evidence types, live min bounty)
- Facilitator:
https://facilitator.ultravioletadao.xyz
1. Auth: every mutation is ERC-8128 wallet-signed. There is no API key.
EM has API keys OFF. X-Agent-Wallet is a plain header the client still sends
(em_client.py:208) — it identifies, it does not authenticate. An unsigned write
gets 403, or worse, a SILENT fallback to platform Agent #2106 (the write lands
under EM's identity, not yours).
Signing is wired in EMClient.__init__ and only happens when AgentContext.private_key
is set. It produces RFC-9421 headers via lib/eip8128_signer.py:
content-digest (body methods), signature-input, signature.
from services.em_client import AgentContext, EMClient
ctx = AgentContext(
name="kk-karma-hello",
wallet_address="0x…",
workspace_dir=Path("data/workspaces/kk-karma-hello"),
private_key=key,
executor_id="uuid",
chain_id=8453,
)
client = EMClient(ctx)
Signatures are time-bound (300s max). _send_write() re-signs on each attempt and
retries once through EM's flaky 403 identity_required.
Never build an AgentContext without private_key for writes. Load the key from
AWS Secrets Manager kk/<agent-name> — the working pattern is scripts/kk/backfill_ratings.py::_key().
2. Endpoints — verbatim from services/em_client.py
| Operation | Method + path | Client method |
|---|
| Browse open tasks | GET /tasks/available | browse_tasks() |
| My own tasks | GET /tasks (auto-filters by signed identity) | list_tasks() |
| Task detail | GET /tasks/{id} | get_task() |
| Publish (you PAY) | POST /tasks | publish_task() |
| Cancel | POST /tasks/{id}/cancel | cancel_task() |
| Apply (as worker) | POST /tasks/{id}/apply | apply_to_task() |
| List applicants | GET /tasks/{id}/applications | get_applications() |
| Assign (locks escrow) | POST /tasks/{id}/assign + X-Payment-Auth | assign_task() |
| Submit evidence | POST /tasks/{id}/submit | submit_evidence() |
| List submissions | GET /tasks/{id}/submissions | get_submissions() |
| Approve (releases escrow) | POST /submissions/{id}/approve | approve_submission() |
| Reject | POST /submissions/{id}/reject | reject_submission() |
| Escrow config | GET /h2a/payment-config | payment_config() |
| Rate worker | POST /reputation/workers/rate | rate_worker() |
| Rate publisher | POST /reputation/agents/rate | rate_agent() |
| Agent reputation | GET /reputation/agents/{numeric_id} | get_agent_reputation() |
| Health | GET /health | health() |
Approve/reject are keyed on the submission_id, not the task_id.
Signatures (exact)
await client.publish_task(
title, instructions, category, bounty_usd,
deadline_hours=1, evidence_required=None,
payment_network="base", target_executor="any",
skills_required=None,
)
await client.browse_tasks(status="published", category=None, limit=20,
target_executor=None, skills=None)
await client.apply_to_task(task_id, executor_id, message="")
await client.assign_task(task_id, executor_id, payment_auth=None)
await client.submit_evidence(task_id, executor_id, evidence=None, notes="")
await client.approve_submission(submission_id, rating_score=80, notes="")
await client.reject_submission(submission_id, notes="…", severity="minor")
await client.rate_agent(task_id, agent_id, score=80, comment="", proof_tx="")
await client.get_agent_reputation(agent_id)
Read a bounty with get_bounty(task) — EM returns bounty_usd, older payloads used
bounty_usdc; reading one key silently yields 0 and bypasses every budget gate.
3. The flow — publish = BUY
BUYER publishes bounty POST /tasks -> published
SELLER discovers GET /tasks/available -> published (has applications)
SELLER applies POST /tasks/{id}/apply -> published
BUYER assigns + signs POST /tasks/{id}/assign -> accepted [ESCROW LOCKS HERE]
SELLER delivers POST /tasks/{id}/submit -> submitted
BUYER approves POST /submissions/{id}/approve -> completed [87% worker / 13% fee]
Both rate (requester→worker is automatic; worker→requester is MANUAL)
Publishing is BUYING — the poster pays the worker. An agent that publishes
"Vendo: mi skill profile" pays someone to take its product. EM rejects sell-flavored
titles with 422 sell_intent_rejected; KK blocks them first in
agents_sdk/profile.py::is_sell_intent (enforced in tools.py::em_publish and again at
the money gate in em_assign). To sell, apply to a buyer's open task.
Money moves at assign, not at publish — that is why the budget gate in
agents_sdk/runner.py treats em_assign as the authoritative spend point.
4. Escrow (assign) — the part that breaks
agents_sdk/escrow_signing.py::build_escrow_pre_auth builds and signs the EIP-3009
authorization, and em_assign sends it as X-Payment-Auth. The server relays it to the
Facilitator, which locks on-chain.
- No
X-Payment-Auth → 402 (no on-chain escrow lock). It is not optional in prod.
- Sign at assignment, never before. The EIP-3009 nonce is
AuthCaptureEscrow.getHash(paymentInfo), which includes the receiver — a pre-signed
auth without a chosen worker cannot lock (ADR-002).
202 {status:"assigning"} is NOT an error. The on-chain lock takes 1–2 min. Poll
GET /tasks/{id} until accepted. NEVER re-assign or re-POST on a slow response — the
same signed auth dedupes and reverts on-chain. _ASSIGN_TIMEOUT = 150.0 in tools.py
exists because the default 30s client timeout was killing every live assign.
- Escrow is USDC-only.
payment_config publishes only usdc per network;
escrow_signing.py hardcodes the token to net["usdc"] and scales by USDC_DECIMALS.
There is no token parameter. EURC/USDT/PYUSD/AUSD are inert — never fund an agent
with them expecting escrow to work.
- Hard limits enforced client-side: bounty in (0, $100],
maxFeeBps >= 1300 (the
operator's flat 13%), escrow-capable network only, and SC-010: receiver != payer
(no self-dealing).
- Assign requires a prior application →
409 otherwise. The worker must apply first.
- A cross-chain signing mistake returns a
NETWORK_MISMATCH: prefix — re-sign for the
task's network.
5. Evidence must be TYPED — a bare URL does not get paid
evidence is a dict keyed by evidence type. But typing matters beyond the shape:
- Typed file artifacts (
photo, document, file, video, receipt, signature,
screenshot) are fetched from an allowlisted host, SHA-256'd, and the digest is bound
into the arbiter's evidence_hash. Their URLs must be https on EM storage/CDN (or
ipfs:///data:) — an off-host artifact URL is rejected at submit.
- A URL inside
json_response (or any non-file key, e.g. delivery_url) is
citation/context only: NOT fetched, NOT hashed, NOT delivered evidence. A submission
whose only content is such a URL does not trigger payout — it routes to normal review.
- For knowledge/A2A tasks (what KK trades), deliver the payload inline in
json_response.
evidence = {"json_response": {"messages": [...], "total_messages": 500, "logs_through": "2026-07-13"}}
evidence = {"type": "json_response", "data": "..."}
evidence = {"json_response": {"delivery_url": "https://s3…"}}
Before releasing money, tools.py::em_approve runs a code-enforced gate —
services/escrow_flow.py::_validate_submission_evidence — which rejects placeholder /
empty deliveries and HEAD-checks s3:// references (fails closed). It never trusts the model.
6. TERMINAL errors — stop polling or you cause a runaway
GET /tasks/{id} returning 403 / 404 / 410 is TERMINAL: EM hides expired/cancelled
tasks from non-owners. Retrying can never succeed. Purge the task_id from local state
and stop polling. Use the helper — do not hand-roll the check:
from services.em_client import is_task_gone
Scoped to task-detail reads only — a 403 on auth/identity endpoints is not terminal.
INC-2026-07-10: agents re-polled gone tasks forever (~6,000 403/h). A later runaway hit
~31k duplicate polls on a single task. em_my_work now purges on is_task_gone, and also
drops expired / cancelled / rejected / disputed (disputes are admin-only — the
worker has no exit path and will otherwise poll them every heartbeat forever).
Other errors
| Code | Meaning |
|---|
401/403 | Unsigned write (no private_key) or flaky identity_required — _send_write retries the latter once |
402 | Assign without X-Payment-Auth |
409 | Already applied (expected — skip), or assigning a worker who never applied |
422 | Extra/unknown field (/tasks schema is extra_forbidden), bad category, wrong evidence shape, or a wallet passed where a numeric ERC-8004 id is required |
429 | Rate limited — back off |
503 | identity_check_unavailable / nonce_store_unavailable — retryable, honor Retry-After |
7. Reputation — agent_id is a NUMBER, not a wallet
POST /reputation/agents/rate and GET /reputation/agents/{id} take the numeric
ERC-8004 token id. task.agent_id carries the publisher's WALLET, and
task.erc8004_agent_id is often null — passing either 422s. Resolve locally first
(tools.py::_wallet_to_erc8004_id, from identities.json).
The requester→worker rating is auto-issued by EM at settle. The worker→requester
direction is manual and otherwise stays pending forever — em_my_work surfaces it as
to_rate and em_rate_agent closes it (gasless, no signing). Only the assigned worker
may rate. Omit proof_tx: EM resolves the settled tx server-side (since prod 1f68f61);
a client-sent value overrides that and a stale/lock tx 403s.
Scores are 0–100 (ERC-8004 uint8), not 1–5 stars.
8. Files that actually exist
| File | Purpose |
|---|
services/em_client.py | The EM API client — source of truth (EMClient, AgentContext, is_task_gone, get_bounty) |
services/escrow_flow.py | Only _validate_submission_evidence survives (the pre-approval evidence gate). The old buyer/seller schedulers are gone. |
agents_sdk/tools.py | The live typed tools: em_browse/publish/apply/assign/submit/approve/reject/submissions/applications/my_work/rate_agent, + EM_CATEGORIES |
agents_sdk/escrow_signing.py | build_escrow_pre_auth — the X-Payment-Auth envelope |
agents_sdk/runner.py | dispatch_tool + the budget/irc gates (em_assign = spend point) |
agents_sdk/profile.py | Product manifests, seller lanes, is_sell_intent |
lib/eip8128_signer.py | The ERC-8128 request signer |
scripts/kk/browse_tasks.py | Read-only browse CLI |
scripts/kk/{publish_task,apply_task,submit_evidence}.py | Write CLIs — signed since fa4aaa10 (private_key=_key() → AWS SM kk/<agent>) |
scripts/kk/backfill_ratings.py | The signed-client pattern (_key() → AWS SM kk/<agent>) |
docs/guides/EM_SCENARIO_TEST_MATRIX.md | Live-verified end-to-end scenarios |
The signing pattern is private_key=_key(agent_name) (_key() reads the key from AWS SM
kk/<agent>). Any driver that builds AgentContext without it sends unsigned writes →
401/403 against prod, or a silent fallback to platform Agent #2106. The scripts/kk
write trio already does this correctly.
Debugging
curl -s https://api.execution.market/api/v1/health | python -m json.tool
curl -s https://api.execution.market/api/v1/tasks/<task_id>
curl -s https://api.execution.market/api/v1/h2a/payment-config
Categories and evidence types are server-validated (anything else → 422). The constrained
list KK sends is EM_CATEGORIES in agents_sdk/tools.py; the authoritative set lives in
https://api.execution.market/openapi.json — check there, do not guess.