Contract: HTTP API and MCP
specs/001-petal-core/contracts/http-api.mdSource 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/healthand/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_versionin 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/meand/api/v1/explorationssend 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:
429with{ error: { code: "rate_limited", retry_after_s } }. An address iscf-connecting-ip, else the socket on a local deployment, and on a public one everyone without it shares a limit.x-forwarded-forandx-real-ipnever 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./mcptakes one call per POST, so a batch is400 bad_input. - Global limits (T300,
.env.example). Daily caps answer429 rate_limitedwithretry_after_suntil midnight UTC. A fresh read past its turn, two at a time, answers the same with a shortretry_after_s, and one that takes too long answers502 source_failed. Past the data budget, a write a stranger can cause answers429 budget_exhausted. - Errors: HTTP status from the error code (
bad_input400,unauthorised401,invite_required403,not_found404,cannot_read422,invalid_spec422,rate_limited429,budget_exhausted429,payment_required402,not_on_this_site403,source_failed503,internal500), body{ error: { code, message, hint } }.source_failedis 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_snapshoton a live pull) acceptAccept: text/event-streamand then streamprogressevents before the finalresultevent.
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.
POST /api/v1/auth/challenge, no body:{ nonce, message, recipient, expires_at }.recipientis the site's hostname;messageis a plain sentence naming it;nonceis 32 bytes as base64, valid 5 minutes, single use. These go to the wallet as they are.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, ored25519:and base58. The key must be a full-access key of the account, read withview_access_keyon that network. An implicit account that doesn't exist yet passes when its id is the hex of the signing key, and only whileview_accountsays it doesn't exist.- 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? }:canis 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 toas(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 }.viewis the person's view (view/v1, theirs or the default): their agent passes it torender_flower, so every flower it draws is drawn as they see it (one-source.md;take_jobgives it too). It answers bywait_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 bypetal.mjswhen 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 }] },canwhat that worker says it can do, or what the agent said through a connector'stake_job, or null: one entry per worker, each agent's in the order they first came.nameis the agent's, fordisconnect;nis 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, withworker: nullandn: 1. Disconnecting an agent takes all its workers, and connecting it again starts from its first.hand_jobtakesto(an agent's name); a job and its event carrytoandtaken_by. A run's system message lists who's connected.- A conversation per job (10 Nov, swarm.md step 2; migration 22).
hand_jobgives 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 carryconversation. Its worker works there:jobs wait,take_jobwith the agent key, andtake_jobwith the project's link (which now answerslink) hand out a continue link for it.GET /api/v1/conversationslists it like any other door's work.report_jobtakes 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/:refthere showspresence: { client, n, … }: the agent's name and which of its workers.list_jobs(GET /api/v1/explorations/:ref/jobs) gives each job'sconversationandn, the place of the worker that took it (1, 2, …, asGET /api/v1/agentscounts 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_seeninlist_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/retryputs a taken, failed or stopped one back as open, for whoever takes it first, andPOST /api/v1/jobs/:job/hand-over { to }does the same for one of their connected agents. Waiting agents wake. Petals' agent does the same withretry_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,unauthorisedwitherror.endedstoppedorhanded_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_jobtakesparts: [{ title, brief, inputs?, to? }], two or more. Each part is a job of its own, for any worker, and all sharepart_ofand one conversation, titled by the job. The run emits ajobevent per part, andlist_jobsand the events carrypart_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 hearsended: stoppedon its next call. The system message's "Agents connected" gives each agent'scan, 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_jobwithagent_keytakescantoo, 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, orpetals.testnet), and dollars per NEAR now from NEAR Intents' token list (nullwhen 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 byaccount_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 atnear_usdnow, 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 } }, withadded_usd: 0the 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, forusddollars of USDC delivered into NEAR Intents under petals.near; the person sendsamount_inof their token todeposit_addressbeforedeadline, and anything 1Click can't swap goes back torefund_to. 1Click adds 0.25% without a partner key.400for an amount over $1,000 or under $0,502when 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.
Explorations and links
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/serverv2 (raw JSON Schema throughfromJsonSchema()). - Local:
petal mcpover stdio (the CLI inapps/clicallsserveStdio), 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, thebuild/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/mcpover 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 markedonTheirMachinethrough 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./mcpdoesn't list them. - The shared map is the copies of published rows the site ships, exactly as FastData returned them (
fixtures/map-rows/), soGET flowers,flowers/:id,flowers/:id/svgandnetworks/:idanswer 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/:sha256anddescribe,figures,specs/proposeandvalidate,render,networks,relations,health,/skill.md. - Health says
agent_access: "none",llm: "none",map: "held"andpublic: 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: gzipallowed. The name must be the sha256 of its canonical JSON and the body a valid snapshot, or400 bad_input; anyone else gets the same404as nothing at all. Answers{ exploration, sha256, stored, origin }:storedis false when the site already held it, which changes nothing, andoriginistheir_machinefor a posted reading, which this site never reads again, orthis_site. Then it serves atGET /api/v1/snapshots/:sha256, andsave_stepwith the spec inline checks against it. At mostPETAL_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/:idlater returns the finished run and its flower. A viewer reconnecting by cursor sees every event once. Stop and a restart end a run asstoppedandinterrupted, with nothing held on the ledger. - Attestations: a stored report is served byte for byte, and its SHA-256 matches
report_sha256. - MCP:
tools/listnames and schemas equal the registry; onetools/callper tool matches the HTTP result.