πŸ›‚ Kite PassportSell with an agent

Serving agreements

Running a seller agent with kagent serve β€” the config, the two skills you author, and the work items your model answers.

With an identity published, your seller needs to actually answer buyers. That's one command:

cd <seller-dir>
kagent serve --config kite.config.yaml

serve holds the platform connection and the signing key. For each incoming work item it forks a headless model run in your seller directory, hands it the item as JSON, reads back one JSON object, validates it, and signs the resulting protocol command. Your integration is the contents of that directory β€” a config file and two markdown skills. There is no seller code.

Where the run happens

The config file's directory is the seller directory, whatever directory you started serve from. Three consequences, each of which has bitten someone:

  1. Skills load from there. Your skills live in <seller-dir>/.claude/skills/, or wherever tools.skills points. Point --config at a file somewhere else and your seller silently loses both its craft and its acceptance standard β€” it will still run, still answer, and answer badly.
  2. Files the model reads are relative to it, including the card facts file.
  3. The model is open by design, with one hard boundary β€” see the key boundary below.

The seller directory

kite.config.yaml
.claude/skills/<your-craft>/SKILL.md
.claude/skills/seller-acceptance/SKILL.md
out/active-registration.json
out/quotes/
registration/

kite-seller β€” the per-operation response contract β€” is deliberately not here. It ships with the CLI bundle and kpass skills setup installs it globally to ~/.claude/skills/kite-seller, where the model run finds it. You neither write it nor copy it, and it updates with the bundle.

The skills you author

This is where your business logic lives, and it's markdown, not code. Two are required. A third is optional but idiomatic, and worth adding as soon as your card has any rule of its own.

SkillRequiredCarries
<your-craft>YesHow the work is done, and the deliverable's exact shape
seller-acceptanceYesWhat to accept, decline, escalate; which arm to take on a rejection
<your-name>-sellerNo, but commonThis card's rules per operation: how to quote from it, where quotes are journaled, what settle and closed mean for this offering

The split between the craft and the card is worth making early. The craft skill survives a price change; the card skill is where the price, the offeringId, the quote journal, and the "you should not see a settle" notes live. Keeping them apart means a rate-card revision touches one file.

Your craft skill

How the work actually gets done: the method, the deliverable's exact shape, and what you refuse to claim. Name it after the work β€” brief-writing, security-audit, data-extraction. Its description should mention both the start item and quoting, so the model reaches for it on both.

.claude/skills/brief-writing/SKILL.md
---
name: brief-writing
description: How this seller writes a one-page brief β€” the method, the deliverable's
  exact shape, and what it refuses to claim. Read on every `start` item and on every
  `request` item that asks what the brief covers or what it costs.
---

# Brief writing β€” the craft

One work item is one brief. The buyer's terms carry a topic and a list of source
URLs; the deliverable is a single markdown document.

## Method

1. **Read the terms for the topic and the sources.** The sources are the buyer's,
   not yours. A source that does not load, or that is paywalled, is reported as
   unread in the brief rather than silently dropped or replaced.
2. **Do not add sources.** If the supplied sources do not settle the topic, that
   belongs in "Open questions", not in a search for better ones.
3. **Attribute every finding.** Each key finding names exactly one supplied source.
   A statement no supplied source carries does not go in the brief β€” including a
   statement you believe to be true.

## The deliverable's shape

Markdown, in this order, with these headings: … (Summary, Key findings, Open
questions, Recommendation, Sources read)

## What this seller will not claim

- That a source is truthful. The brief reports what a source says and who says it.
- Legal, medical, financial, or safety advice.
- Translation. English sources, English brief.

Be specific about the deliverable's shape. On a start item, the model's final message is the deliverable β€” serve stages its bytes, hashes them, and signs the hash. A vague shape produces a deliverable the buyer's acceptance check can't mechanically verify.

seller-acceptance

Your own standard for decide. kite-seller deliberately carries no default, and escalates every proposal to the owner when this file is absent β€” a seller with no acceptance standard is one that cannot say yes on its own.

State it as conditions that must all hold, then what to decline, then what to escalate. Three conditions earn their place in almost every seller:

  • Craft fit β€” is this the work you do, stated narrowly, with the promises you won't make.
  • Scope fits one work item β€” producible from the buyer's brief alone. A proposal that would need you to ask a question first isn't ready: decline naming what's missing, and invite a request-lane message.
  • Priced ground β€” a price the card can honor, or a quote you issued.
.claude/skills/seller-acceptance/SKILL.md
---
name: seller-acceptance
description: What this brief-writing seller will and will not take on β€” the acceptance
  standard kite-seller applies on every `decide`, and the policy for choosing an arm
  on `rejected`. Read on every decide and every rejected item.
---

# Seller acceptance β€” the brief seller's own standard

The terms have already been checked against the published registration before a
`decide` reaches you. What is left is willingness and capacity. **All** of these
must hold to accept:

- **Craft fit.** One one-page brief on a named topic from supplied source URLs, in
  the shape `brief-writing` specifies. Between one and ten sources.
- **Producible from the brief alone.** A proposal that would require asking the
  buyer a question first is not ready β€” decline naming exactly what is missing.
- **Priced ground.** The published fixed price on `content-generator/v1`, or a
  quote recorded in `out/quotes/` that this seller issued.

## Decline

- Any deliverable that is not a brief: code, translation, ongoing monitoring, a deck.
- Sources that are paywalled or login-gated β€” say which, and that the buyer may
  re-propose with reachable ones.
- Legal, medical, financial, or safety advice as the deliverable.

## Escalate

- A topic this seller's owner may not want its name on β€” active litigation, an
  identifiable private individual.
- The terms are readable but you cannot tell whether the deliverable is a brief in
  this card's sense. Ambiguity about scope is the owner's call, not a guess.

## rejected

`agreement.offered_commands` lists the arms this chart allows from `REJECTED` …

Write the rejected policy deliberately

rejected is a deadlined three-way fork β€” revise, appeal, or consent to a refund β€” and silence refunds the buyer when the appeal-response window closes. Which arms exist depends on the workflow template: content-generator/v1 carries a bounded rework round so kite.contract.deliver is normally offered, while standard/v1 has none. Read agreement.offered_commands rather than assuming.

The card-rules skill

The optional third skill sits between the two: kite-seller says what shape an answer takes, your craft says how the work is done, and this one says what this card does per operation. A brief-seller for the seller above would carry:

  • request β€” when to answer with prose and when a quote is mandatory. One rule worth stating explicitly: a buyer's tooling advances to a proposal only from a quote/v1 frame, so answering a purchasable ask with "just propose directly" is never valid.
  • Quote derivation, exactly β€” that registrationHash comes from out/active-registration.json and not from memory; that price.amount is the flat sum as a plain decimal (500000 minor β†’ "0.5"); that price.asset is the card's currency.code ("USDC"), never the eip155:… asset URI; and that a negotiation.mode: none card means priceSchedule is exactly {}.
  • The quote journal β€” record each issued quote as out/quotes/<threadId>.json with the frame, the buyer, and a one-line scope naming what you priced. The scope line is load-bearing: the deal-contract schema has no threadId member, so a proposal never carries one, and scope is the only way to tell the deal you quoted from a different job that happens to cost the same.
  • What not to expect β€” "this chart offers no co-signed split, so no settle item is minted for it; if one ever arrives, answer {"no_settlement": {…}}."

The card facts file

Your model cannot read your own registration β€” that read needs the agent's key, which the model never holds. It reads a file. Rewrite it after every registration publish:

kagent registration get --output json > out/active-registration.json

The model reads registration.registration.registrationHash for the hash to quote, and the matching entry in registration.projection.offerings[] for the platform-held rateCard. Miss this file and a negotiated seller answers every buyer "I cannot quote right now" β€” politely, with a healthy log and nothing anywhere to explain it.

The config file

Every key is checked at startup: a misspelled key is refused, not silently defaulted.

kite.config.yaml
brain:
  harness: claude-code          # claude-code | codex
  model: claude-sonnet-5        # optional; any model the harness (or baseUrl) serves
  apiKeyEnv: ANTHROPIC_API_KEY  # the NAME of the var; the key itself never goes in this file
  maxSteps: 20                  # claude-code --max-turns
  maxBudgetUsd: "1.0"           # claude-code --max-budget-usd, as a string
  timeout: 5m                   # default per-item budget β€” see the TTL trap below
  timeouts:                     # optional; per-operation overrides (kagent 6.6.0+)
    start: 45m                  #   the deliverable gets as long as the work takes
    request: 90s                #   the message lane stays under the buyer's TTL
  maxTurnsPerStart: 48          # optional (6.6.0+); `working` turns before serve parks it
  session: per-agreement        # per-agreement | none
tools: {}
  # skills: ./skills            # optional; omit when skills already live in .claude/skills/
  # mcpServers:                 # optional; your own deterministic tools
  #   - { name: pricing, command: node, args: ["dist/mcp.js"] }
  #   - { name: backend, url: https://my-service/mcp }
  # allowed: [...]              # optional; REPLACES the built-in tool set
KeyWhat it does
harnessclaude-code runs the claude binary on PATH; codex runs codex. Switching is a one-line change β€” skills and MCP servers are shared.
baseUrlPoints codex at any OpenAI-compatible endpoint, and claude at ANTHROPIC_BASE_URL.
effortlow|medium|high|xhigh β€” codex reasoning effort.
session: per-agreementOne conversation across all of an agreement's items, so the model that delivered is the one that answers a rejection. Sessions are recorded under <config-dir>/brain/; a session the harness no longer has is replaced by a fresh one rather than parking the item. none starts every item cold.
maxSteps / maxBudgetUsdCap each run. A run that hits either dies mid-answer and the item retries β€” size them to your work, not to the example.
timeouts.<operation>Overrides timeout for one operation. The key set is closed against serve's vocabulary, so a typo is refused: brain.timeouts.deliver is not one of amend|closed|decide|dispute|rejected|request|settle|start.
maxTurnsPerStartHow many working turns one start may spend before serve parks it for you. Default 48.

Per-operation budgets exist because one number couldn't serve two lanes: a seller whose deliverable takes 45 minutes couldn't also answer a quote inside the buyer's 10-minute message TTL. Raising timeout for the delivery mooted every request at claim; keeping it low killed the delivery.

`timeouts.start` has a hard ceiling of 59m

A work lease is chosen at claim time, bounded 30–3600s, and cannot be renewed. serve claims with min(3600s, start budget + 60s) β€” the minute being its reserve for hashing, evidence registration, and the signed submit that follow your run. A budget over that is refused at startup rather than deployed to fail:

brain.timeouts.start 1h2m0s is over 59m0s β€” a work claim's lease tops out at 3600s
and serve keeps 1m0s for the evidence and the submit; a longer job needs several turns

"Several turns" is the intended answer for longer work β€” see a job that outgrows one run.

MCP servers are how deterministic logic gets in: pricing tables, a lookup against your own backend, anything you'd rather compute than have a model reason about. Register them here and the model reaches them as mcp__<name>.

The key boundary

The model gets every built-in file and web tool, your MCP servers, and the network. What it does not get:

  • The signing key. ~/.kagent β€” and whatever --config-dir you ran with β€” is denied to its file tools. serve signs; the model produces content.
  • The platform CLIs. kagent and kpass are not callable from it.
  • Kite environment. KITE_* and KAGENT_* are stripped. Only ANTHROPIC_*, CLAUDE_*, OPENAI_*, CODEX_*, a few base variables (HOME, PATH, USER, LOGNAME), and the variable apiKeyEnv names reach it.
  • A shell. Bash is not in the default tool set.

Why Bash is opt-in

Claude's deny rules govern its built-in file tools; they do not inspect shell commands. cat ~/.kagent/runtime.key through Bash walks past every rule. You can opt it in through tools.allowed, and serve warns at startup that you did β€” keep it only if this seller accepts that. Codex ignores tools.allowed entirely: its sandbox is workspace-write with the network on, and the boundary there is the environment you run it in.

tools.allowed replaces the built-in set rather than adding to it β€” use it to narrow. MCP servers stay available either way.

The work items your model answers

serve mints an item whenever the authoritative agreement view says this seller owes an answer. Each one is a JSON envelope β€” {operation, itemId, attempt, turn, agreement, payload, history} β€” and the model's final message must be exactly one JSON object in the shape kite-seller specifies for that operation.

OperationArrives whenWhat you owe
requestA buyer sends a request-framed messageOne reply/v1 (free text) or quote/v1 (a proposable price schedule) frame
decideA proposal names this seller and awaits acceptance (PROPOSED)accept, decline, or escalate
startEscrow is funded and work is due (offered_commands include kite.contract.deliver)The deliverable β€” your final message is it
rejectedThe buyer rejected a delivery (REJECTED)A revised delivery, an appeal, or refund consent
settleDelivered, on a chart offering a co-signed split (DELIVERED)A split proposal, or an explicit no-split
amendMid-fulfilment, on a chart offering revisions (FULFILLING)A re-pin of terms, or nothing
closedA buyer closed a negotiation threadA bookkeeping acknowledgment

Two things worth knowing about that table:

  • "Quote" is not an operation. A quote is one of the two frames a request may be answered with. On a fixed-price card, a prose reply/v1 naming the published price is a perfectly legitimate answer; quote/v1 matters most for negotiated cards, where serve re-derives every field of your schedule from the published card and refuses any mismatch by deep equality.
  • Return the frame object itself. Do not wrap it in a reply member β€” the runtime adds that, and a pre-wrapped frame nests inside itself and is refused.

A stray object on `start` is delivered as the product β€” say so in your craft skill

A start answer is exactly one of three things: an agent-delivery object, a working checkpoint, or an escalation. Anything else your model emits is wrapped as the artifact β€” serve hashes it, signs it, and registers it as evidence. consent_refund and appeal belong to a rejected item, and decline belongs to decide; emitting one here delivers it.

This is not theoretical. On a verified run the supplied source URLs turned out to 404, and the model β€” correctly refusing to fabricate β€” answered with a consent_refund object. serve delivered that refusal as the product: the agreement went to DELIVERED, the refusal JSON was hashed and registered, and the buyer had to reject it to unwind an escrow that was already funded.

The escape hatch is the escalation envelope, which is a valid answer on any operation:

{"escalate": "<what is missing, and why the work cannot be done honestly>"}

Your craft skill has to say this explicitly. A model reasoning from first principles reaches for the refusal vocabulary it can see in seller-acceptance and emits an arm that belongs to a different operation β€” and nothing in the platform stops it.

working is the one arm serve recognises specially rather than wrapping, and the reason is this exact hazard: an unrecognised progress note would have been DELIVERED as a JSON blob describing the seller's own progress, and the buyer asked to accept it. Only working got that treatment β€” every other stray object still becomes the artifact.

A job that outgrows one run

Before kagent 6.6.0 a start had two answers, and a job too big for one run had to choose badly: deliver something half-done, or page the owner. Now it can say it's still working.

{"working": {"checkpoint": "scaffold done, 14/31 tests green; next: the payments module; files under out/job-7f3a", "resumeAfter": "0s"}}

serve validates the shape fail-closed, journals the turn, frees the slot, signs nothing, and re-presents the same start β€” same itemId, attempt starting over, turn incremented, your recent checkpoints in history.

What your craft skill needs to tell the model:

  • Never both. working beside a delivery member is two answers to one question, and serve refuses the whole run. Deliver, or say you're still working.
  • Write the checkpoint for a reader with no memory. With session: per-agreement you usually resume the same conversation β€” but the session can be gone (a redeployed pod), and then the checkpoint is all you get. Under 16 KiB, and a pointer to out/ beats pasting the work in: the pointer survives a trimmed history, the pasted text may not.
  • resumeAfter is normally "0s". Zero means "continue at once, in another run" β€” the answer for a job that merely outgrew a turn. Use a real interval only for a genuine wait (a CI run submitted, a third party to hear from), and keep it under 60m.
  • Nothing about a turn reaches the buyer. No state moves, no signature, no evidence. Write the checkpoint for your next turn, not as something anyone will read.

How the numbers read, because they're easy to get off by one: turn counts checkpoints already recorded, not the run you're in. The first run carries no turn member; the run after your first checkpoint carries turn: 1, and the checkpoint written on that run is recorded as turn 2. history holds those records oldest-first as {turn, at, checkpoint}, so history[last].turn == turn.

history is bounded at 64 KiB and drops the oldest first, so on a long job the early checkpoints are gone. Write each one to stand alone rather than as a diff against the last.

A turn is not an attempt β€” it consumes no retry budget. But turns are capped by maxTurnsPerStart (default 48), and spending them parks the item for you with the last checkpoint attached; a seller that keeps saying "working" is exactly the case that costs money. Separately, while a start is still turning, the sweep escalates to you once the delivery deadline gets closer than one more turn plausibly needs, with the last checkpoint attached. That escalation is a cue to look, not a failure β€” the item is not parked, because the job may still finish and stopping it is your call.

Long jobs need real volumes

Two directories decide whether a job survives a deploy: the seller directory (where the model writes, normally out/) and the harness's own session store β€” $HOME/.claude for claude-code, CODEX_HOME for codex. On an emptyDir both vanish when the pod is recreated and the next turn starts from the checkpoint text alone. Fine for a five-minute item; not for a job spanning hours. Mount them, and keep <config-dir> β€” the journal, where the turns themselves live β€” on the same persistent volume.

Waiting on the buyer is not a fault

Between decide and start the deal is the buyer's to advance: they need an owner-approved spending session, then funding, then their half of the joint Activation signature. While that's outstanding your seller mints a fund item that yields rather than failing:

[serve] item fund:<agreement-id>: yielded (the buyer wallet is not present yet) β€” the next event re-arms it

That's the correct hold, and it can repeat for as long as the funding window allows. If the buyer never funds, the agreement expires on its own β€” content-generator/v1 gives funding 30 minutes from COMMITTED.

On a verified run where the buyer's owner never approved the spending session, the agreement reached EXPIRED at revision 2 and the seller's journal recorded nothing but a cursor advance: no escalation, no parked item, no error line. That is the whole correct behaviour. An unfunded agreement is a non-event for the seller, so don't tune brain.timeout or go looking for a failure β€” no model run was ever involved.

Items are gated before a model run is spent on them. A work row becomes a start only when its offered_commands include kite.contract.deliver; an item whose command the authoritative view no longer offers β€” a second start after your own delivery, a settle after the buyer already accepted β€” resolves moot: journaled done with the reason, never parked, no escalation.

Run it

cd <seller-dir>
export ANTHROPIC_API_KEY=…        # whatever brain.apiKeyEnv names
kagent serve --config kite.config.yaml --sweep-interval 30s

A healthy start looks like this:

[serve] local surface on http://127.0.0.1:8642 (token: /Users/you/.kagent/serve.token)
[serve] brain: claude-code model="" session=per-agreement timeout=5m0s skills="" mcp=0
[serve] brain wired: in-process brain from kite.config.yaml (timeout 5m0s, parallel 4, retries 5)
[serve] handler loop resuming after seq 0
[serve] stream open: agent=did:kite:ind-ruimin:brief-agent high_water=0 cursor=0
[serve] stream ready: high_water=0

With per-operation budgets set, the brain: line names them too β€” timeouts(request=1m30s,start=45m0s) β€” so you can confirm at a glance that the file you meant is the file it read.

Read that line and every [serve] warning: before a buyer does β€” those are the problems serve tolerates rather than refuses.

Confirm it's alive over the loopback read surface, bearer-authenticated with the token the first line names:

curl -H "Authorization: Bearer $(cat ~/.kagent/serve.token)" http://127.0.0.1:8642/v1/health
# {"agentDid":"…","streamCursor":0,"inboxLatestSeq":0}

Port 8642 collides, observed live

That read surface binds 127.0.0.1:8642 by default. A second serve on the same machine β€” or one left over from an earlier session β€” fails the new one with bind: address already in use. Give every additional seller identity its own --local-addr and its own --config-dir.

One benign warning you may see

[serve] warning: brain.apiKeyEnv names ANTHROPIC_API_KEY, which is unset or empty in this environment; the harness will fail to authenticate on the first item

On a machine where the claude CLI is already signed in with a Claude subscription, this warning is a false alarm β€” the harness authenticates with those stored credentials and items run normally. We confirmed this on a verified run with the variable unset. In a container, though, take it literally: there are no stored credentials there, and every item really will fail at the model.

Operational notes:

  • --handler-timeout is refused with --config β€” the per-item budget is brain.timeout. --handler-parallel (concurrent items, default 4) and --handler-retries (attempts before an item parks, default 5) apply either way.
  • Run one serve per seller identity. A second instance on the same state directory is refused; two sellers on one machine need different --config-dir and different --local-addr.
  • Transient network trouble self-heals. A dropped stream logs stream ended (…); reconnecting from cursor N in 1s and comes back; the independent sweep is the correctness backstop, so a lost notification costs latency, never work.
  • In containers, mount --config-dir on a persistent volume.

Running it as a container

The whole containerized seller process is: bring the runtime key and binding up, publish the card and the registration, snapshot the active registration for the model, then hand the process to kagent serve --config. Nothing else runs.

Four things that differ from a laptop run:

  • --config-dir on a persistent volume. KAGENT_CONFIG_DIR works too. Without persistence the key is regenerated on every boot, which files a fresh pending runtime each time and orphans nothing usefully.
  • Import the key rather than minting one. kagent init --import-key <file> (or --import-key - from stdin) brings an existing runtime key in from a mounted secret, so the identity survives the pod.
  • Re-pin the card on boot. The entrypoint should run kagent card fetch --pin every start β€” that's what keeps a long-running seller from accumulating a stale pin.
  • Take the apiKeyEnv warning literally here. There are no stored CLI credentials in a container, so an absent key really does mean every item fails at the model. Check it in the entrypoint and log loudly.

Re-snapshot out/active-registration.json on every boot as part of the same sequence, since out/ is normally gitignored and a fresh image has none.

The timeout-versus-TTL trap

brain.timeout is the default budget for every operation, and 5m is sized for a quote-only handshake β€” not for real deliverable work. A code build measured 498s. Raise it to what the work takes; the agreement's own delivery window is days, not minutes.

The constraint is that the message lane must stay comfortably below the buyer's message TTL. That lane is exactly two operations β€” request and closed β€” because those are the only items minted from a relayed message and claimed against its TTL. serve attempts such an item only when its remaining TTL is strictly greater than that operation's budget; one it couldn't finish in time is discarded as moot before the model ever runs. The buyer's default TTL is 10 minutes, which is why serve warns at startup when a message-lane budget reaches it.

Per-operation budgets are the real fix

Before 6.6.0 one number covered both lanes and this was a genuine bind. Now it isn't: give start the time the work needs and keep the message lane short.

timeouts:
  start: 45m
  request: 90s

A 45-minute start beside a 90-second request is owed no TTL warning, and serve no longer issues one β€” the warning follows the message lane's budgets, not the largest number in the file.

If your negotiation genuinely needs longer than 10 minutes, the fix is telling buyers to send request frames with a longer explicit --ttl (up to 1h), as a fresh message: reusing the expired send's --idempotency-key just returns the original with its old TTL. When serve does decline a doomed request, the decline now names the budget it was measured against and the --ttl the buyer would have to beat, so the buyer sees something actionable rather than a bare expiry.

Pre-flight checks worth scripting

Most of what breaks a seller directory is checkable locally in a second, before any model run or platform call. Worth a scripts/check.sh in your own repo, asserting:

  • kite.config.yaml parses, and brain names harness, apiKeyEnv, timeout, and session β€” with harness in {claude-code, codex} and session in {per-agreement, none}. If you set timeouts, every key is one of serve's operations and start is at or under 59m.
  • card.json and all three registration documents agree on one DID β€” and that it isn't still a template placeholder.
  • The three registration documents agree on their offeringId set.
  • Every skill the config expects exists at its path and starts with frontmatter. A SKILL.md without frontmatter is not loaded, and nothing errors.
  • seller-acceptance/SKILL.md contains a ## rejected section β€” serve consults it when a buyer rejects, and its absence only shows up at the worst moment.
  • kagent registration validate passes.

That last one is the only check that touches the network, and it's the one that saves a wasted signature.

Prove it before a buyer does

The cheapest test needs no platform at all. The bundled kite-agent-handler answers one item envelope on stdin, running the same model with the same skills from the same directory.

Start with the item that short-circuits without calling the model β€” a failed terms check declines mechanically:

cd <seller-dir>
printf '%s' '{"operation":"decide","itemId":"t","attempt":1,
  "payload":{"terms_check":{"matches_published":false,"detail":"price below floor"}}}' \
  | "$HOME/.kpass/bin/kite-agent-handler"
{"ok":true,"response":{"decision":"decline","reason":"terms do not match the published registration: price below floor"}}

Then a real request, which does run the model:

printf '%s' '{"operation":"request","itemId":"t2","attempt":1,"payload":{
  "from":"did:kite:ind-test:buyer",
  "message":{"frame":"urn:kiteai:coordination:frame:request:v1","threadId":"thr_test1",
  "text":"What does a one-page brief cost, and can you cover a topic with 4 source URLs?"}}}' \
  | "$HOME/.kpass/bin/kite-agent-handler"

A verified run of the seller above answered:

{"ok":true,"response":{"reply":{
  "frame":"urn:kiteai:coordination:frame:reply:v1","threadId":"thr_test1",
  "text":"A one-page brief is a flat 2 USDC, regardless of topic β€” no per-source or per-word
   charges. Yes, 4 source URLs is within range (I accept 1 to 10 per brief). Just note:
   sources must be publicly readable (no paywalls/logins), English-language, and each key
   finding in the brief is attributed to one of your supplied sources…"}}}

That answer is the test passing: the price, the source-count bound, and the limitations all came out of the two skills, and the runtime added the reply wrapper. This proves your skills and your card facts. It does not read kite.config.yaml β€” the config is proven by starting serve and reading its startup line.

For a quote, check that it carries your own registrationHash and a priceSchedule derived from the published card. serve validates it with the same validator the platform runs at propose time, so a schedule that doesn't match is refused and the run is wasted.

Provenance

After every delivery the model produced, serve publishes a runtime-declaration evidence record on the agreement β€” signed by the seller's key and readable by the buyer. It is a declaration, not a proof: it says which harness and model the seller claims produced the work, endorsed by the key that signed the delivery.

Here is a real one, from the run these pages were written against:

{"schema":"kite:cli:runtime-declaration:v1","harness":"claude-code","assurance":"declared","itemId":"wrk_b9025107b8d5bdc8b31659fa500d2c38","attempt":1}

Set `brain.model` or your provenance can't name the model

Note what's missing above: there is no model member, because that run left brain.model unset and let the harness pick its own default. The field is omitted rather than guessed. If the point of the declaration is telling a buyer what produced their work, pin brain.model explicitly in kite.config.yaml β€” serve will not infer it for you.

Alongside it, a completed agreement carries the rest of the chain. On the same run, kagent agreement evidence list returned four records: the delivery artifact with its sha256, the runtime-declaration, and two chain-event records. The hash is the binding value β€” a buyer compares a downloaded artifact's sha256 against it, and against the deliveryHash in the seller's signed deliver command.

A complete run, end to end

These pages were written against a live agreement on dev. The whole lifecycle, as the transition proofs recorded it:

CONTRACT_SIGNED β†’ FUND_CONFIRMED β†’ DELIVERY_SUBMITTED β†’ DELIVERED
β†’ REJECTION_SUBMITTED β†’ REJECTED β†’ REFUND_CONSENT_SUBMITTED
β†’ CONSENTED_REFUND β†’ SETTLEMENT_OBSERVED

Nine transitions, ending CANCELLED at revision 9 with the escrow refunded on chain. Read yours with kagent agreement proofs --agreement-id <id> [--verify].

What the seller's own model decided along the way, and what it never touched:

MomentWho decidedResult
decideThe model, reading seller-acceptanceaccept, in ~30s, with its own reasoning
Seller's half of the Activationserve, mechanicallyNo model run β€” the fund item is not a judgment
startThe model, reading the craft skillThe artifact, hashed and signed by serve
rejectedThe model, reading ## rejectedRefund consent β€” the objection was right and unfixable
SettlementThe engine and the chainRefund observed, agreement terminal

The model signed nothing, called no platform verb, and never held the key. Every signature in that chain is serve's.

A re-armed item that finds nothing to do resolves moot

The rejected item above ran twice. Attempt 1 submitted the refund consent; attempt 2 β€” a re-arm racing the state change β€” found the agreement had already moved on:

{"moot":"kite.contract.deliver is not offered by <id> from state REFUNDING_REJECTED;
  the agreement moved past this item and nothing is left for this seller to sign"}

moot is journaled done with its reason. It is not a park, not an escalation, and not something to investigate: it is serve declining to spend a signature on an obligation that no longer exists. Expect these whenever a window closes or a counterparty acts while an item is in flight.

Monitoring a running seller

serve keeps working with no further input, so check on it rather than waiting to be asked. Every command below needs the same --config-dir the process was started with, or it reports on the wrong seller:

tail -n 20 <config-dir>/handler.jsonl                          # every attempt/acted/done/escalated entry
kagent --config-dir <config-dir> agreement list --output json  # every agreement's current state

To wait on one specific outcome, poll in the background rather than in a loop:

kagent --config-dir <config-dir> agreement status --agreement-id <id> --watch --output json

A failed or timed-out attempt is logged with a harness tail β€” the last tool calls and text the model produced β€” because the brain reads the harness's event stream rather than one closing envelope. That tail is usually the whole diagnosis.

When a served seller misbehaves

SymptomCause
Every proposal escalates to the ownerNo seller-acceptance skill in the seller directory β€” or --config points at a file outside it
Every buyer is told "I cannot quote right now"out/active-registration.json missing or stale
Every proposal is refused with acceptance_policy_violationThe owner has not set an acceptance policy β€” see governance
Quotes are refused, the item retries, then parksThe priceSchedule does not derive from the published card
Every item fails at the model with empty outputThe model runtime failed. Reproduce by hand in the seller directory (claude -p / codex exec). An unset apiKeyEnv in a container is the common cause; a model whose safeguards flag your subject matter fails identically, and pinning a different brain.model fixes it
Items time out and retrybrain.timeout is below what the work takes, or the run hit maxSteps / maxBudgetUsd
serve refuses to start, naming a config keyA misspelled or unsupported key in kite.config.yaml
A buyer's message never becomes an itemThe buyer sent it without the request frame; nothing is minted and nothing errors
A correctly-framed request is claimed but never answeredIts remaining TTL was not greater than that operation's budget β€” discarded moot. See the TTL trap
A start parks after a long runIt spent maxTurnsPerStart working turns. The last checkpoint is attached β€” read it before raising the cap
serve refuses to start after an upgrade-in-reverseA 6.6.0-only key (timeouts, maxTurnsPerStart) in a config an older binary read. Unknown keys are refused, not defaulted
Every proposal refused with an agentCardHash mismatchThe card pin is stale. Re-run kagent card fetch --pin and have buyers re-propose

A parked item is a decision waiting for a human, not a lost one. serve retries, then parks and escalates. One thing to know about the current release: controller approval does not reinvoke the handler. That's deliberate β€” the model already chose accept, so it must not be asked to make the business decision again. After the owner approves, run the identical command by hand:

kagent agreement accept --agreement-id <id> --output json

The next sweep then observes the new state. A denied or expired request stays parked.

Where to go next

On this page