DocsThe API

Contract: HTTP API and MCP

specs/001-petal-core/contracts/http-api.md

Source contract. Dated notes and future phases retain their original status.

On this page

Version: api/v1 | Owners: W4 (tool routes, MCP), W5 (agent routes) | Consumers: web (W6), skill, CLI and IronClaw package (W7), any agent, judges

One Hono app, petalApp(cfg) from @petal/agent, made of two parts:

  • Tool routes: httpApp(ctx) from @petal/tools. Every tool in tools.md has one route; bodies and responses are the tool's input and output. Also /api/v1/health and /mcp.
  • Agent routes: agentRoutes(...) from @petal/agent: chat, runs and attestation reports. They live with the agent because the agent imports the tools; one-way dependencies, no cycle.
  • Auth routes: from @petal/agent: sign-in, the session and invites (below). Mounted before the tool routes, so their open CORS doesn't answer for them.

Locally pnpm api (or petal api) runs petalApp under Node on http://localhost:8787. On a host it is mounted in the web app at /api/[[...route]] with @hono/vercel. The HTTP API is the base door; the skill, the CLI, MCP and the IronClaw package sit beside it over the same tools (packages.md).

Rules

  • JSON in and out, UTF-8. tools_version in every response.
  • No account, key or payment is needed for anything except publishing and Petal's own agent (FR-030, SYS-051).
  • CORS: open on the tool and agent routes, so agents and other sites can call them, with no Access-Control-Allow-Credentials, so a browser never sends a session across origins. The auth routes, /api/v1/me and /api/v1/explorations send no CORS headers.
  • Rate limits per address (in memory in Phase A): 60 tool calls per minute; 10 new agent chats per hour, plus 60 follow-ups per hour on an existing run. A person spending their own credit isn't held to the 10 new chats (T374): the follow-ups still count. Over the limit: 429 with { error: { code: "rate_limited", retry_after_s } }. An address is cf-connecting-ip, else the socket on a local deployment, and on a public one everyone without it shares a limit. x-forwarded-for and x-real-ip never count. An IPv6 address counts as its /64.
  • A body is at most 256 KiB, a chat 512 KiB, and a posted reading 2 MiB on the wire and 32 MiB inflated: 400 bad_input. /mcp takes one call per POST, so a batch is 400 bad_input.
  • Global limits (T300, .env.example). Daily caps answer 429 rate_limited with retry_after_s until midnight UTC. A fresh read past its turn, two at a time, answers the same with a short retry_after_s, and one that takes too long answers 502 source_failed. Past the data budget, a write a stranger can cause answers 429 budget_exhausted.
  • Errors: HTTP status from the error code (bad_input 400, unauthorised 401, invite_required 403, not_found 404, cannot_read 422, invalid_spec 422, rate_limited 429, budget_exhausted 429, payment_required 402, not_on_this_site 403, source_failed 503, internal 500), body { error: { code, message, hint } }. source_failed is 503, not 502, because Cloudflare, in front of the live server, swaps an origin's 502 or 504 for its own page, and the person would read that instead of the message.
  • Long tools (get_snapshot on a live pull) accept Accept: text/event-stream and then stream progress events before the final result event.

Routes

Method and path Tool
GET /api/v1/look-up?query=&chain= look_up
GET /api/v1/search?q=&kinds=&limit= search_petals (#105): the map for anyone, a person's own work and annotations for them alone; the last word read as the start of words; each answer with a snippet
GET /api/v1/find?query=&chain=&limit= find_target
POST /api/v1/read-chain read_chain
GET /api/v1/read-page?url=&start= read_page
GET /api/v1/web-search?query= search
GET /api/v1/networks?query= list_networks
GET /api/v1/networks/:id get_network (params as query)
POST /api/v1/snapshots get_snapshot
GET /api/v1/snapshots/:sha256 snapshot body, application/json, gzip; immutable cache headers
GET /api/v1/snapshots/:sha256/describe?top= describe_snapshot
POST /api/v1/figures compute_figures
POST /api/v1/specs/propose propose_spec
POST /api/v1/specs/validate validate_spec
POST /api/v1/render render_flower (?format=svg returns image/svg+xml). With no view of its own, it's drawn through the signed-in person's view, or the default (one-source.md, step 2). For a signed-in person it also carries their own names, from their named annotations (core's namePieces), on the petals they name where the view's labels show name (annotation step 6). A visitor's carries none, and a response with a person's names is Cache-Control: private, no-store. The map's lists (/api/v1/map, /map/views, /maps, /maps/:id) read the same way. For a signed-in person with names, the lists' picture addresses also carry &names=<hash of their named claims' ids>. /map/:sha/svg with names draws them only for the person whose set that is now, and answers private, no-store whoever asks. Pictures without names stay as they were, kept a year by view, and carry no one's
GET /api/v1/flowers?network=&maker=&limit= list_flowers
GET /api/v1/flowers/:id get_flower
GET /api/v1/flowers/:id/svg the published flower's SVG, drawn with the view its address names (?view=<whole hash>, none: the default; an unknown hash is not_found)
POST /api/v1/publish/prepare prepare_publish
POST /api/v1/publish publish_flower
POST /api/v1/quote quote_payment (dry, FR-031)
GET /api/v1/brain brain_index (005): every chapter's and recipe's header, with the brain commit
GET /api/v1/brain/route?q=&answers= brain_route: the best three for a question, the calls a contract answers (comma-separated), or both
GET /api/v1/brain/files/:id brain_file: one, whole, by id; doctrine is read first
POST /api/v1/explorations/:ref/layers keep_layer (005): a layer kept privately under the project's owner, and the dataset version it makes; owner or continue link
GET /api/v1/explorations/:ref/layers?about=&hash= list_layers: the owner's layers and versions about an address, or one layer whole; any link
POST /api/v1/explorations/:ref/annotations add_annotation (10 Nov, annotation-layer.md): { annotations: [claim/v2 without id and by], as? }, one or many, all or none, kept privately under the project's owner. Who answers for each (by) comes from the door: the signed-in person's account, or, on a continue link, tool:<as>@1. → { added, existed, ids }. Owner or continue link; migration 21
GET /api/v1/explorations/:ref/annotations?about= list_annotations: this project's, oldest first and in the order sent, or with about everything the owner holds about that account, from any of their projects. Each { claim, exploration, added_at }. Any link
GET /api/v1/me/annotations?verb= 11 Nov, annotation-layer.md step 5: signed in, everything the person and their agents keep about accounts, from all their projects, oldest first; verb=named for the names the page draws on petals with core's namePieces. Each { claim, exploration, added_at }. 401 for a visitor
POST /api/v1/me/annotations 11 Nov: signed in with a wallet, what the person adds from the page ("Add a note"): { annotations: [claim/v2 without id and by], conversation? }, all or none, with their account as by. With conversation (a run id, or an exploration id of theirs) it's kept in that conversation's project, a run's made now if it has none; without, it's theirs in no project, and GET /api/v1/me/annotations lists it with exploration: null. → { exploration, added, existed, ids }. 404 for a conversation that isn't theirs
(Petals' agent) annotate 11 Nov, annotation-layer.md step 3: its own tool, one per run. It keeps what the person tells it ("this is Dekan's wallet": named, stated) or what it found, in the conversation's project, through add_annotation, with the person's account as by and via: tool:petals-agent@1. In the same message as its answer, kept, it ends the turn at no model call of its own; one not kept goes back to the model. The page's "Add a note" posts the same to POST /api/v1/explorations/:ref/annotations with the person's session: { annotations: [{ subject, verb: "noted", object: { value }, kind: "stated", evidence: [] }] }
POST /api/v1/explorations/:ref/datasets/fork fork_dataset: a new version from one of the owner's, { version, add?, drop? }, naming it as parent; owner or continue link
POST /api/v1/figures/compare compare_figures (Phase B)
POST /api/v1/corrections/prepare prepare_correction (Phase B)
POST /api/v1/corrections submit_correction (Phase B)
GET /api/v1/relations/account-groups?account=&limit= account_groups
GET /api/v1/relations/shared?group_a=&group_b=&limit= shared_accounts
GET /api/v1/relations/related?group=&by=&limit= related_groups
GET /api/v1/relations/overview?min_shared=&kind=&limit= relations_overview
GET /api/v1/map none: one answer, {made, source, views, groups, pairs}. Every view stored on the site with its name, reading, about (what was read and where, in people's words: its reader's title and its chain, such as "ERC-20 token holders on Gnosis"), address, date, snapshot, params, view_url, picture, keys (the colours its picture draws, [{ label, swatch }] as its legend has them: colour's, then the tail's) and made_by, and the holdings groups and pairs relations_overview gives (10 Nov, one map)
GET /api/v1/maps none: {maps}, one card per map (Elco, 10 Nov: "maps that map the same address need to come together as versions of the same map"). A map is one address, in core's one form (mapOf: chain:address, an EVM address in lower case, or chain:population for a whole network), and every view carries it as map. A card is { id, name, title, about, chain, address, picture, view_url, keys, mark, value, proposals, readings }, the newest first by its newest drawing: name is its newest named drawing's (null when nobody named one), the rest its newest drawing's, and address is what tells two maps of one name apart (null for a whole network). readings are its sediments, the newest first by date, each { snapshot, as_of, network, about, title, count, made_by, drawings }, made_by the maker of its first drawing, and drawings { sha, name, view_url, picture, keys, made_by }, the oldest first. The holders and the delegates of one token are two readings of one map; two drawings of one reading sit under it. Until readings are layers (005), a map's versions are its readings in date order. mark (board #85) is what tells its organisation from another, read by scripts/map-marks.ts (the chain first, then curated lists), or null until it's read: { name, symbol, logo, read_at }. name (the organisation's) and symbol (its token's) are each { value, status, source, ref, verified_by?, at }, or null. logo is { sha256, media_type, status, source, ref, at, url }, or null, and url is the picture on this site, `/api/v1/media/.<png
GET /api/v1/maps/:id/proposals none: {proposals}, the map's proposals/v2 (packages/core/schema/proposals-v2.schema.json; v1 kept before 11 Nov still reads, proposals.schema.json; board #93): its source (the DAO's kind, contract, the calls and when), and each proposal { id, title, kind, proposer, at, closes_at, decided_at, outcome, as_said, ref, votes }, each vote { account, vote: for | against | abstain, as_said, at }. v2 adds outcome: closed (voting closed and the source doesn't say the result, never a guess), each proposal's recipient (whom it pays: a Sputnik Transfer's receiver_id; null when it names none) and options (its options' labels, where a ballot chooses among them), and each vote's choices [{ choice, weight }] and power, weights as decimal text; its vote is null when the choices aren't for, against or abstain. A Baal proposal's proposer is the sender of its submission. An account that didn't vote isn't in votes: absent, never a guess. Accounts are the map's petal ids. A Sputnik DAO records no time for a vote nor when a proposal was decided (at, decided_at null); closes_at is its policy's proposal period from submission. A Baal (Moloch v3), behind a map of its shares token, gives each from its events: every vote's time, closes_at from when its vote starts, decided_at from its processing or cancelling. not_found when none were read
GET /api/v1/maps/:id/claims none: {about, claims}, Petals' own mapping's claims about the map (Elco, 11 Nov: "show them"): the waves' checked claims whose subject is one of the map's accounts (any of its readings' petals, or the map's own address), oldest first, each in the open claim format as kept (claim/v3 or v4: id, subject, verb, object, kind, by, issued_at, evidence, and method, qualifiers when inferred). Match a petal by its id against subject (CAIP-10). None names a person: the loader checks each as a published claim. The same for every viewer. not_found for a map that isn't on the map
GET /api/v1/maps/:id none: {map}, one card as GET /api/v1/maps has it, by its id URL-encoded (an EVM address in any case); not_found for an address with nothing on the map. A map hidden from the Power Map (board #96: packages/tools/src/hidden-maps.json, each with its reason and who decided) is in no list (/api/v1/maps, /api/v1/map, /api/v1/map/views, and the agents' look_up and list_flowers), but answers here with hidden: { why, decided }; nothing is deleted, its flower still opens, and taking its line out brings it back
GET /api/v1/map/views none: {views}, the same views in the same order (the oldest first), each without its reading. The one source for any list of what's on the map: the gallery, the lobby's "On the map", /make's opening (Elco, 10 Nov). A view opens at its view_url (/d/<sha>)
GET /api/v1/map/:sha/svg none: a view's still picture, drawn with the view its address names, as a flower's tile is, Cache-Control: immutable, ETag its render_sha256; not_found for a hash that isn't on the map, or a view nobody kept. A view's picture is this address with core's pictureQuery (?look=<render version>&view=<the viewer's view, its whole hash>&paint=<the palette>), so a cached picture is never an old look or another person's: use it as given. Twelve characters of the default's hash, as addresses carried before, are still the default. It has no per-address limit, since a gallery asks for every picture at once
?view=<hash> on GET /api/v1/map, /api/v1/map/views, /api/v1/maps, /api/v1/maps/:id Their pictures, colour keys and readings are drawn through that view (one-source.md, step 2), the default without it; not_found for a view nobody kept. A flower's own choices never draw it: they're the record of what its agent proposed. Petals' agent draws through the person's view, so what it says about a flower is what they see
GET /api/v1/relations/path?account_a=&account_b=&max_depth=&budget=&include_inferred= power_path
GET /api/v1/explorations?limit= list_explorations (below)
POST /api/v1/explorations start_exploration
GET /api/v1/explorations/:ref get_exploration; :ref is an id or a link's token
POST /api/v1/explorations/:ref/steps save_step, body without exploration
POST /api/v1/explorations/:ref/notes add_note, body without exploration
POST /api/v1/explorations/:ref/claim a session takes an unowned exploration with its continue link: { exploration, claimed }
GET /api/v1/explorations/:id/links owner only: { links: [{ id, scope, created_at, revoked_at }] }, with no tokens
POST /api/v1/explorations/:id/links owner only, body { scope: "read" | "continue" }: { id, scope, url, created_at }, its token shown this once
POST /api/v1/explorations/:id/links/:link/revoke owner only: { id, revoked_at }
GET /d/:hash a short draft link: 302 to /draft?spec=, relative, with the spec hashed again on read. 404 for an unknown hash
GET /s/:token a link's resume packet as markdown, text/plain, no-store, for an agent that can only fetch a URL. 404, plain text, for anything else
POST /api/v1/agent/chat Petal's agent, SSE (below). With Accept: application/json alone: 202 { run_id, status: "running" } at once, and the run goes on unwatched. Agent route. Refused without a sponsored invite when gated.
GET /api/v1/agent/runs/:id a stored run: { run_id, status: "running" | "done" | "failed" | "stopped" | "interrupted", messages, events, flower?, exploration? }; with Accept: text/event-stream, replays stored events from the cursor (Last-Event-ID, or ?from=<seq>), then streams new ones until the run ends. Agent route.
POST /api/v1/agent/runs/:id/stop Stop (below). { run_id, status, stopped, already_finished }. Agent route.
GET /api/v1/attestations/:sha256 the NEAR AI attestation report Petal stored for a model signature (report_sha256), application/json, immutable cache headers. Agent route.
GET /api/v1/health `{ ok, map: "up"
POST /api/v1/auth/challenge { nonce, message, recipient, expires_at } (below)
POST /api/v1/auth/verify body { network, proof }: { person_id, network, wallets, claimed_runs, claimed_explorations }, and sets the session
POST /api/v1/auth/logout { ok: true }, and clears the session
POST /api/v1/auth/invite body { token }: { invite }, and holds it in this session
GET /api/v1/auth/invite { invite } this session holds, or { invite: null }
GET /api/v1/me { person_id, network, account, wallets, credit }, to that person only; 401 to anyone else. network is the wallet this session came in with, and nothing else reads it; account (additive, 9 Nov) is that wallet's account id, or null. credit is { funded_usd, spent_usd, left_usd, usd_per_chat }, or null for a person with no credit of their own (T374)
GET /api/v1/me/view { view, hash }: the person's own view (view/v1), the glasses every petal they see is drawn through, or the default for a visitor and anyone who hasn't set one (Elco, 10 Nov; one-source.md, step 1). It's the person's, not a wallet's
GET /api/v1/me/usage { usage, totals: { calls, charged_usd, reconciled, billed_usd } }, signed in: every model call charged to the person, the newest first, each a usage/v1 record (packages/core/schema/usage.schema.json; coordinator/pricing-protocol.md): NEAR AI's inference id for the call (its inference-id header, the UUIDv5 of the answer's id), the tokens in, cached and out, the list price they were counted at, what was charged, and NEAR AI's own bill once POST /v1/billing/costs knows it. The person is charged the bill: a difference goes back or is charged as its own entry, "reconciled with NEAR AI's bill". It reconciles first, as each chat does before it starts
GET /api/v1/price { price, hash }, to anyone: what a person pays, price/v2 (packages/core/schema/price-v2.schema.json; the live one is packages/agent/src/price.json; it replaces price/v1, whose schema stays readable). Elco, 10 Nov: "there's three components to the price": NEAR AI's own bill, plus a base fee (A a percent of each call's bill, B a percent of each top-up, C an amount per turn, D an amount per use of a tool by name, E a free allowance of NEAR AI's bill before any of it; every switch off), plus the person's tip, a donation at a percent of their usage on the price's scale, 0 by default. Each part is its own entry on the credit: "Petals base fee (call
GET /api/v1/me/tip, PUT { tip_percent } { tip_percent, min_percent, max_percent, step_percent }, signed in: the person's tip, a percent of each call's bill, a donation charged apart from the fee; 0 until they set one (today's scale 0 to 50 in steps of 1, the numbers Elco's); null puts it back. Off the scale is 400
PUT /api/v1/me/view { view } → { view, hash }, signed in, same-origin: checked against view/v1 (400 with the reason), kept by its hash, with the session's credential and the time beside it. 401 for a visitor. Putting the default back is a PUT of the default
GET /api/v1/views/:hash { view, hash }: any view by its hash, the default's too without anyone keeping it; not_found otherwise
GET /api/v1/me/profile { profile, updated_at, published }: the person's profile in NEAR Social's own shape (api2.near.social/standard.md, social-kv/1), null until they keep one. published stays null until publishing to NEAR Social is on. 401 for a visitor
PUT /api/v1/me/profile { profile }, the whole of it → the same as GET, signed in, same-origin. profile is { name?, about?, avatar?, banner?, location?, links? }, each optional and null to clear: name and location 256 characters at most, about 10,000, links up to 32 of service: value (service ^[a-z0-9_]{1,32}$, value a handle or an https:// URL, 2,048 at most). avatar and banner take a data:image/webp, jpeg or png base64 picture, which is kept here under its sha256 and becomes its address on this site, or that address, or a fastfs:// address as on chain. Anything else is 400 with the reason
GET /api/v1/media/:sha256.:ext a profile's picture, to anyone, by its sha256 (as FastFS names an upload, media/{sha256}.{ext}), Cache-Control: immutable
ALL /mcp MCP Streamable HTTP, stateless

Agent chat (SSE)

POST /api/v1/agent/chat, body { "messages": [{ "role": "user" | "assistant", "content": string }], "draft"?: { "spec"?, "entry"? }, "run_id"?: string }, response text/event-stream. With run_id, the chat continues a stored run (its messages and draft are loaded).

Runs you can leave and come back to. Every chat is a run, stored by RunStore (.petal/private.db), and a job in the one server process. The SSE stream only observes it, so a viewer coming or going changes nothing. GET /api/v1/agent/runs/:id returns it, finished or not.

Reconnecting. Each stored event has a place in the run's log, its seq: 0, 1, 2…, across all the chats of the run. An event's SSE id is its seq. Last-Event-ID: <n> starts after n; ?from=<n> starts at n; if both are sent the header wins. A heartbeat carries no id, and isn't stored. A chat's own stream starts at the chat's first event, so its ids carry on from the run's earlier chats. A run's stream ends when the run does; a stream that ends without done or an error says nothing about how the run ended, so read the run.

Stop. POST /api/v1/agent/runs/:id/stop aborts the model call and the tool in progress, settles the budget for what was used, and answers once the run has ended. It reports what it witnessed: { status: "stopped", stopped: true, already_finished: false }, or { status: <how it ended>, stopped: false, already_finished: true } for a run that had already finished. If neither can be said (status: "running", no job holds it), both are false. Whoever holds a run's id may stop it, as they may continue it. Budget for an aborted call counts only what it is known to have cost.

A restart. No run is resumed: that would spend money with nobody watching. On boot, every run still running gets an error event with code interrupted and that status, no done follows it, and every hold on the ledger is released. Holds lost this way aren't counted as spent, because what those calls cost can't be known. A process that only builds the app (a test, a check) leaves alone the runs of a live process that started a chat on the same store. A chat with run_id continues a stopped or interrupted run from its saved thread, which is saved after each model answer and each tool.

Event Data
run { run_id, resume_url }, always the first event; resume_url is /make?run=<run_id>
stage { stage: "recorded" (v1.1, additive: first stage of a recorded run) | "thinking" | "reading_map" | "fetching" | "counting" | "proposing" | "validating" | "drawing", message }
progress { stage, message, done?, total? } (forwarded from tools)
tool_call { id, name, args }
tool_result { id, name, ok, summary, ms } (summaries, never raw snapshot bodies)
message { text } one complete assistant turn
approval_required { tool, network, params?, target_key }, additive (J4): Petal's own agent reached for a new map before the person said yes, and the tool didn't run. At most once per target per chat, stored like the rest, so a surface can show Yes and No. A chip sends "Yes" as an ordinary message: no new field, no new route
flower { spec, spec_sha256, render_sha256, view_url, legend, interpretation, validation }
job additive (9 Nov): { id, exploration, kind: "map", title, brief, status }. Petals' agent handed a job to the person's own agent (hand_job, its own tool). It lives in the conversation's exploration, which the run gets now if it had none; the person's agent takes it there (take_job, report_job), each change signals GET /api/v1/explorations/:ref/live, and GET /api/v1/explorations/:ref/jobs says where it stands. The run's next chat carries every job's status and report in its system message, as data from that agent.
title additive (10 Nov, Elco: conversations are named): { title }. Petals' agent named or renamed this conversation with name_conversation, its own tool, in the same message as its answer. The run's system message says the name it has, or that it has none yet, so the agent names it with its first answer and renames it when the subject changes. A round whose only calls are name_conversation, with words beside them, ends the turn: a name costs no model call of its own. GET /api/v1/conversations lists the new name.
signature ModelSignature for the completion that proposed the spec
error { code, message, hint }; besides the tool codes, stopped (followed by done) and interrupted (last)
heartbeat {} every 10 s, so idle connections stay open
done { turns, model, fallback_used, tokens: { in, out, cached? }, cost_usd, signature?, picked? }; cached (additive, 10 Nov) is the part of in NEAR AI read from its cache, which cost_usd counts at the model's cache-read price, as NEAR AI bills it. An attempt that failed without saying its cost (a timeout) and was answered by the next model counts at that input in full; signature is the completion that wrote the answer, null when it got none (T168). model is what answered and picked what the conversation asked for, when it asked: they differ after a fallback, and with turns: 0 nothing answered (T371)

The yes (J4, SYS-054). On a real model, a new map waits for the person: a fresh read through get_snapshot, and propose_spec and render_flower. A get_snapshot with no yes behind it is answered from the newest snapshot stored for that target, or the one its at names, however old, unless it asks for fresh. It's marked stored_only, reaches no source, and approves nothing. target_key is the network, plus its params in canonical form when it has any. A call goes ahead if its target, its snapshot, or the spec it varies is already approved in this run, including a flower already drawn and a draft the person sent. Otherwise it goes ahead if the latest user message tells the agent to draw something by name, even as a first message, unless the target is a candidate of an ambiguous: true find_target. Or if that message is a yes, the agent has said something before it, and what it last said leaves one thing to agree to: no either-or in its last question, no numbered list. One yes covers one new target. Otherwise the model's tool result is { error: { code: "approval_required", message, hint } } and this event goes out. The rest of the chat keeps the tools that only read, and loses propose_spec and render_flower unless something is already approved. Asking again for what was refused isn't run, and a model left with no words gets one call with no tools, so it has to speak. A reply naming a candidate by ordinal, chain or address counts for that candidate when the last find_target said ambiguous: true. What's approved is read back from the run's thread and flowers, so it survives run_id and a restart, with no new column. A recorded run isn't held. This is consent, not security: a client can write its own history, and the invite budget is the boundary. approval_required has no HTTP status, because it never leaves the loop: the tool routes and MCP are not held (SYS-030).

A conversation's settings.model and settings.effort are what its chats run on (T370): the catalogue is the allow-list, effort goes only where the model offers it, and a fallback stays inside its promise, so a signed pick never becomes unsigned and a passed-through model with none fails plainly. Limits per chat: 8 tool rounds, 300 s wall time (PETAL_RUN_MAX_S), $0.05 on an invite, and PETAL_PERSON_USD_PER_CHAT, default $0.50, on a person's own credit. A daily cap (PETAL_LLM_DAILY_USD, default 3) protects the one server key; past it, including mid-way through a chat, the agent returns budget_exhausted and points to the skill (bring your own agent).

Model calls inside a chat are not streamed, so each one can carry a TEE signature (streamed completions get a weaker gateway signature). Progress comes from Petal's own events instead.

Without NEAR_AI_API_KEY, the server answers with a recorded transcript (recordedClient) and says so in done.model = "recorded". That keeps the judge path working with no credentials (SC-009).

Sign-in, sessions and invites

The server reads three settings. PETAL_API is the site's public address: the recipient, the allowed Origin and the cookie's name all come from it, never from a request. PETAL_AGENT_ACCESS is gated, the default and what anything unrecognised means, or open. PETAL_WALLET_KEY seals wallet addresses, and is 32 bytes as hex or base64. A server that is gated and has a model key refuses to start without it. Elsewhere (open, NODE_ENV development or test, no model key) a throwaway key is made at the first sign-in, with one warning line, and wallets sealed under it can't be read after a restart.

A person is a private id, prs_<hex>, with wallets attached. A wallet is a binding, near:<network>:<account>: testnet and mainnet are different wallets, so different people, even for the same implicit account. Addresses are stored sealed, with a keyed hash as the unique index.

Sign-in, NEP-413 only.

  1. POST /api/v1/auth/challenge, no body: { nonce, message, recipient, expires_at }. recipient is the site's hostname; message is a plain sentence naming it; nonce is 32 bytes as base64, valid 5 minutes, single use. These go to the wallet as they are.
  2. POST /api/v1/auth/verify, body { "network": "testnet" | "mainnet", "proof": Nep413Proof }, where a proof is { type: "nep413", account_id, public_key, signature, message, nonce, recipient }. A signature may be base64, base58, or ed25519: and base58. The key must be a full-access key of the account, read with view_access_key on that network. An implicit account that doesn't exist yet passes when its id is the hex of the signing key, and only while view_account says it doesn't exist.
  3. On success, in one step: the person is found or created, the wallet bound, an anonymous session's runs and explorations move to them, and a new session replaces the old one, keeping its invite. { person_id, network, wallets: [{ caip10, network, account_id, proved_at }], claimed_runs, claimed_explorations }. A first sign-in reads the same as a returning one.

Refused with unauthorised (401): another message, another recipient, a bad signature, a nonce that's unknown, used or expired, a key that isn't a full-access key of the account. NEAR's RPC not answering is source_failed (502), and the same proof can be sent again. A person signing in with another wallet changes person; nothing is merged. Linking a second wallet (SYS-023) isn't built yet.

A session is an opaque 256-bit value, of which only the hash is stored, with a subject that is a person id or an anonymous handle (anon_<hex>). It lasts 30 days from when it's made. The cookie is HttpOnly, SameSite=Lax, Path=/: __Host-petal_session and Secure when PETAL_API is https://, otherwise plain petal_session. A session can read and continue its subject's runs and draw on an invite, and can't approve anything irreversible: publishing still takes a signature.

Origin. A POST to an auth route, to /api/v1/agent/chat on a real model, or to .../stop answers unauthorised when its Origin isn't the site's. On a local site (no PETAL_API, or localhost, 127.0.0.1), a local page matching the Host it asked is the site. With no Origin and no Sec-Fetch-Site: cross-site it isn't a browser, and it passes.

Run owners (SYS-014). A chat on a real model creates its run with the session's subject as owner, opening an anonymous session if there is none. Once a run has an owner, only that owner may read, follow, continue or stop it. To anyone else it answers exactly as a run that never existed: 404 { error: { code: "not_found", message: "no run <id>" } }. The owner never appears in a run. A recorded run opens no session and has no owner; it and any run older than owners stay open to whoever holds the link, and continuing one leaves it unowned. There is no way back to an anonymous session's work once its cookie is lost.

Who pays (SYS-050 to SYS-052). With agent_access: "gated", a chat on a real model needs a person with credit of their own or a session holding an active invite. A person's credit (T374) belongs to the person, so any of their wallets and any browser reach it, it comes before an invite the session holds, and it stops a chat at PETAL_PERSON_USD_PER_CHAT, default $0.50. The daily cap doesn't hold it (Elco, 9 Nov: the cap is for unpaid spend only). On a public site, its runs read the chain, pages and search from the server (Elco, 9 Nov), while the site's own doors still read nothing. When it ends, the chat's stream carries error with payment_required, and the next chat answers 402 with the same message unless the session holds an invite. A signed-in person with no credit and no invite gets 402 payment_required too (9 Nov): they pay for Petals' agent in NEAR, below. The rest of this section is about invites, whose caps don't change. Without one it spends nothing, stores nothing, and answers 403 { error: { code: "invite_required", message, hint } }, with hint pointing to /connect. The recorded demo, reading a run, and every tool need no session. open is for an operator's own tool: no invite is asked for, and the daily cap still applies.

A person's agents, across their projects (9 Nov, coordinator/agent-house-test.md). The person copies one prompt into whichever agents they want; it carries an agent link, which can only wait for jobs. The work itself goes through each job's project, on a continue link.

  • POST /api/v1/agents/link (signed in, same-origin) → { id, token, created_at }: the token, petal_agent_…, is shown once and only its hash is kept. A new link leaves the older ones working. GET /api/v1/agents/link → { links: [{ id, created_at }] }; POST /api/v1/agents/link/revoke { id? } revokes one, or all.
  • POST /api/v1/agents/wait, Authorization: Bearer <token>, { as, worker?, wait_s?, can? }: can is what this worker says it can do, in its own words, such as whether it splits work among sub-agents and on which models (Elco's swarm lean 2, 11 Nov; migration 26), kept with the worker, the latest wait's winning and null when it says nothing. It waits for an open job sent to as (case ignored), or else to anyone, in any of the person's projects, and takes it, only while the request is still open. → { job: { id, kind, title, brief, inputs, to }, project: { exploration: <a continue link url>, question }, view }, or { job: null }. view is the person's view (view/v1, theirs or the default): their agent passes it to render_flower, so every flower it draws is drawn as they see it (one-source.md; take_job gives it too). It answers by wait_s (default 50) and never later than 90 s, because the tunnel in front of the site closes a request idle for 100 s; the agent's own command asks again. A wait that's open right now is the agent's presence.
  • worker (10 Nov, swarm.md step 1; migration 20) is the window or sub-agent asking: 1 to 32 letters, digits, - or _, made by petal.mjs when it joins. Several workers of one name each take their own job. Without it, a wait is as before.
  • GET /api/v1/agents (signed in) → { agents: [{ name, n, worker, waiting, last_seen, can, job: { id, title, exploration } | null }] }, can what that worker says it can do, or what the agent said through a connector's take_job, or null: one entry per worker, each agent's in the order they first came. name is the agent's, for disconnect; n is the worker's place, 1 for the first, so the page reads them "Codex", "Codex 2" (it names agents: readableAgent). An agent that never waited as a worker is one entry, with worker: null and n: 1. Disconnecting an agent takes all its workers, and connecting it again starts from its first.
  • hand_job takes to (an agent's name); a job and its event carry to and taken_by. A run's system message lists who's connected.
  • A conversation per job (10 Nov, swarm.md step 2; migration 22). hand_job gives each job its own conversation: an exploration of the person's, in the handing conversation's folder, titled by the job, with the brief as its question. A job and its event carry conversation. Its worker works there: jobs wait, take_job with the agent key, and take_job with the project's link (which now answers link) hand out a continue link for it. GET /api/v1/conversations lists it like any other door's work. report_job takes either conversation's link, and its summary (or why it failed) lands in the job's own conversation as a note, as well as in the conversation that handed it out. GET /api/v1/explorations/:ref there shows presence: { client, n, … }: the agent's name and which of its workers. list_jobs (GET /api/v1/explorations/:ref/jobs) gives each job's conversation and n, the place of the worker that took it (1, 2, …, as GET /api/v1/agents counts them). An older job, with no conversation, works in its project as before.
  • Supervision, without timers (11 Nov, swarm.md step 3; migration 23). Each take of a job with its own conversation hands its worker a continue link, which the job remembers. Every call through it marks the job alive: last_seen in list_jobs, from which the page says it's quiet. POST /api/v1/jobs/:job/stop (signed in, one of theirs) stops an open or taken job. POST /api/v1/jobs/:job/retry puts a taken, failed or stopped one back as open, for whoever takes it first, and POST /api/v1/jobs/:job/hand-over { to } does the same for one of their connected agents. Waiting agents wake. Petals' agent does the same with retry_job { job, to? }, and its job notes say who holds each taken job and when it was last seen. Once a job is stopped or sent again, its old worker's next call through that link is refused, unauthorised with error.ended stopped or handed_over, telling it to report nothing and wait for the next one. → { job: { id, status, to, taken_by, conversation, updated_at } }; 404 for anyone else's job, 400 for one in the wrong state.
  • A job in parts (11 Nov, swarm.md step 4; migration 24). hand_job takes parts: [{ title, brief, inputs?, to? }], two or more. Each part is a job of its own, for any worker, and all share part_of and one conversation, titled by the job. The run emits a job event per part, and list_jobs and the events carry part_of. When the last part reports, Petals' agent takes them all up at once, as one turn ((Petals) All N parts of the job came back…), and only the report that completes the group starts it. Its job notes number each part ("part 2 of 4").
  • Managing jobs from the chat (11 Nov, Elco's swarm leans 2 to 4). Petals' agent has stop_job { job }, by the person's stop rule: an open or taken job of its own conversation stops, and its worker hears ended: stopped on its next call. The system message's "Agents connected" gives each agent's can, its own words, as data. hand_job's parts are for sending work to different agents; one agent alone, or one that says it runs sub-agents, gets the whole job and splits it itself. take_job with agent_key takes can too, kept as the agent's own.
  • When a report lands (report_job), Petals' agent takes it up at once: a new turn in the run that handed the job out, with a user message starting (Petals) The job "…" came back from …, paid from the person's own credit. With none, or the run already going, the report waits for the person's next message. The conversation's live signal fires, so a page that follows it reads the run again.

Paying in NEAR (9 Nov; specs/003-target/proposal-payment.md). A signed-in person adds to their own credit with one plain NEAR transfer from their wallet to Petals' account. The server holds no key; it reads the transaction back from the chain. A public site counts mainnet NEAR only, since testnet NEAR is free and NEAR AI bills real dollars; a site that isn't public (a lane, the journey server) takes testnet too, so the step can be walked for nothing.

  • GET /api/v1/pay → { network, receiver, near_usd }: the network the person signed in on (mainnet for anyone else), where to send NEAR (petals.near, or petals.testnet), and dollars per NEAR now from NEAR Intents' token list (null when it can't be read).
  • POST /api/v1/pay { network, account_id, tx_hash }, same-origin, signed in. The server asks NEAR's RPC for the transaction's FINAL outcome, sent by account_id, which must be one of the person's wallets on that network. A successful transaction whose Transfer actions send NEAR to the receiver goes onto the person's credit, in dollars at near_usd now, under its hash, so it counts once however often it's sent. → { added_usd, added_near, near_usd, credit: { funded_usd, spent_usd, left_usd, usd_per_chat } }, with added_usd: 0 the second time. Refusals: 401 unauthorised (not signed in, or not their wallet), 404 not_found (NEAR has no such transaction), 400 bad_input (not a successful NEAR transfer to the receiver, or the wrong network), 502 source_failed (the chain or the price couldn't be read; nothing changed).

A top-up through NEAR Intents (10 Nov; Elco: "let users pay with anything from anywhere"). Mainnet, as NEAR Intents is, from any token on any chain its 1Click lists, with one plain transfer from whatever wallet the person has and no key given to anyone.

  • GET /api/v1/pay/tokens → { tokens: [{ asset, chain, symbol, decimals, usd }] }: what a person can pay from, as 1Click lists it.
  • POST /api/v1/pay/intents { usd, origin_asset, refund_to }, same-origin, signed in → { top_up: { deposit_address, deposit_memo, origin_asset, amount_in, amount_in_formatted, amount_in_usd, amount_out, amount_out_usd, deadline, usd, status } }: a real 1Click quote, EXACT_OUTPUT, for usd dollars of USDC delivered into NEAR Intents under petals.near; the person sends amount_in of their token to deposit_address before deadline, and anything 1Click can't swap goes back to refund_to. 1Click adds 0.25% without a partner key. 400 for an amount over $1,000 or under $0, 502 when 1Click can't quote it.
  • GET /api/v1/pay/intents/:deposit_address → { top_up: { deposit_address, status, credited_usd, origin_tx, destination_tx }, credit }, theirs only: where the swap stands (1Click's own status: PENDING_DEPOSIT, KNOWN_DEPOSIT_TX, PROCESSING, SUCCESS, INCOMPLETE_DEPOSIT, REFUNDED, FAILED). At SUCCESS the dollars it delivered go onto their credit, once, as their own entry, at the price they came in at, and Petals' top-up fee if that switch is on (price/v1).

Invites. scripts/mint-invite.ts <usd> [days] makes one, for 14 days unless told otherwise, and prints its token once; only a hash of the token is stored. --revoke <id> ends it. POST /api/v1/auth/invite, body { token }, puts it in this session, opening an anonymous one if needed. Several sessions can redeem one token, and they draw on one budget. { invite: { status: "active" | "spent" | "revoked" | "expired", budget_usd, spent_usd, left_usd, expires_at } }. A token that is unknown, revoked or expired answers unauthorised; a spent one budget_exhausted.

Each model call of a chat reserves against the invite and the daily cap, then settles both, so the last call can pass the budget by what it costs. When the budget ends, the chat's stream carries error with budget_exhausted and a plain message, then done, and the next chat answers 429 with the same message before any model call. An invite revoked or expired mid-chat stops at the next call the same way, and the next chat answers 403 invite_required. A restart gives back every hold on an invite.

402 from NEAR AI. budget_exhausted with a plain message for each kind: api_key_limit_exceeded (the key's own lifetime limit), insufficient_credits, no_credits, no_limit_configured (the sponsor account) and any other (unknown). None is retried on another model, and the figures in NEAR AI's message stay out of the event.

The tools are in tools.md; these routes add a session. They read the cookie, so they send no CORS headers, Cache-Control: no-store, and a POST from another site answers unauthorised. To anyone without access an exploration answers 404, one body whatever the reason.

A link is 32 random bytes in base64url, and only its hash is stored. Making, listing and revoking links takes the owner's session, an anonymous one included, and never a link. An exploration moves with its anonymous session at sign-in. A site's run makes one at its first flower, and each new flower is a step, door site.

In the web app: apps/web/app/d/[hash]/route.ts and apps/web/app/s/[token]/route.ts are route handlers with no page, which pass to this app as /mcp's does.

MCP

  • Remote: ALL /mcp, Streamable HTTP, stateless, built with @modelcontextprotocol/server v2 (raw JSON Schema through fromJsonSchema()).
  • Local: petal mcp over stdio (the CLI in apps/cli calls serveStdio), for Claude Code and Codex. The one install command: claude mcp add petal -- pnpm -s --dir <repo> petal mcp, where <repo> is the root of the clone (locally, the build/ folder).
  • Same tool names, input schemas and outputs as tools.md. Progress goes out as notifications/progress.
  • IronClaw loads it only through a packaged extension: skill/ironclaw/ wraps /mcp over HTTP and ships the skill. It is a hedge, never on the demo's critical path; the skill and the HTTP API work without it.

Public mode (PETAL_PUBLIC=1, additive, 8 Nov)

Elco, 8 Nov: Petals runs on each person's own computer and AI. A public site does no work for a visitor that reaches outside it: no chain reads, web reads, searches, model calls or chain writes. The container turns it on whenever Petals/public-url exists. The person's own agent runs the tools on their machine (CONNECTORS' /petal.mjs) and posts what it made.

  • Refused, 403 not_on_this_site, with a hint to /connect: POST /api/v1/agent/chat; the tools marked onTheirMachine through every door: find_target, look_up, read_chain, read_page, search, get_snapshot, prepare_publish, publish_flower, quote_payment. Their routes: GET find, look-up, read-page, web-search; POST read-chain, snapshots, publish/prepare, publish, quote. /mcp doesn't list them.
  • The shared map is the copies of published rows the site ships, exactly as FastData returned them (fixtures/map-rows/), so GET flowers, flowers/:id, flowers/:id/svg and networks/:id answer with no chain read. There is no writer.
  • Kept: sign-in (its one RPC check of the signing key is the site's own job), /api/v1/me, the workspace, explorations and links, /d, /s, GET snapshots/:sha256 and describe, figures, specs/propose and validate, render, networks, relations, health, /skill.md.
  • Health says agent_access: "none", llm: "none", map: "held" and public: true. The model key is never loaded.
  • PUT /api/v1/explorations/:ref/snapshots/:sha256 (both modes): a reading made on the person's own machine, so the site can draw it. The owner or a continue link; the body is the snapshot JSON, Content-Encoding: gzip allowed. The name must be the sha256 of its canonical JSON and the body a valid snapshot, or 400 bad_input; anyone else gets the same 404 as nothing at all. Answers { exploration, sha256, stored, origin }: stored is false when the site already held it, which changes nothing, and origin is their_machine for a posted reading, which this site never reads again, or this_site. Then it serves at GET /api/v1/snapshots/:sha256, and save_step with the spec inline checks against it. At most PETAL_POSTED_SNAPSHOTS_PER_DAY (200) new ones a day, and none past the data budget.

Contract tests (W4 tool routes, W5 agent routes)

  • Every tool route returns what its tool returns for the same input (fixture snapshot, fake map).
  • SSE: a recorded agent run produces the event order run → stage → tool_call → tool_result … → flower → signature → done.
  • Runs: after the client disconnects mid-run, GET /api/v1/agent/runs/:id later returns the finished run and its flower. A viewer reconnecting by cursor sees every event once. Stop and a restart end a run as stopped and interrupted, with nothing held on the ledger.
  • Attestations: a stored report is served byte for byte, and its SHA-256 matches report_sha256.
  • MCP: tools/list names and schemas equal the registry; one tools/call per tool matches the HTTP result.