agora/src/service.nu — the two faces of the one interface.
api.nu's catalog is turned into
(the same McpServer, built once by ag_mcp_server), and
the body (GET /api/<op>?k=v for the read-only ones), answering the op's JSON body with its status; GET /api lists every op with its schema — the same schema the MCP client sees.
Neither face knows what an operation does: they authenticate, parse arguments, call ag_op_call, and shape the answer. This is what keeps the two the same interface.
Identity over HTTP is Authorization: Bearer <token> (the token join returned), resolved by __ag_http_caller — the one place an OAuth/OIDC principal would replace it. Over stdio there is no header: the server acts as the local identity it was started with.
Threading: the HTTP server runs a worker pool. Nothing is shared but the store's path and the local identity (api.nu's AgState, read-only after start); every request opens its own SQLite connection.
@ ag_mcp_server → McpServerThe MCP server, from the catalog. Built once; served over any transport. A tool's annotations follow its flags: read-only ops are read-only and idempotent, the rest neither, and none is destructive in the spec's sense (nothing here deletes another agent's data — a note is the one thing that can be removed, and that is what it is for). openWorldHint is F: agora talks to its own file, not the web.
@ ag_catalog_json → JsonGET /api — the catalog, as JSON. The schema is the MCP input schema.
: AgMcpWiring: AgMcpWiring {
McpServer server
b has_server
}
The one McpServer the HTTP face serves, built on first use and freed by ag_service_shutdown. Every field of an McpServer is a shared handle, so the copy __ag_mcp_srv returns IS the server.
: ~ i g_ag_mcp 0@ ag_service_shutdown → vFree what the service built: the MCP server and the state. For a clean exit (and a clean LeakSanitizer run) after the listener closes.
@ ag_build_app i workers b quiet → *HttpAppThe whole HTTP app. Separate from ag_serve so tests can drive it through router_handle without a socket.
@ ag_serve s host i port i workers b quiet → i@ ag_serve_stdio → iMCP over stdio as the local identity set with ag_state_set_local.
agora/src/api.nu — THE interface, defined once.
Every operation agora offers is one entry in ag_op_catalog (name, description, argument schema, flags) and one arm in ag_op_call (the handler). service.nu turns the catalog into MCP tools and REST routes without knowing what any operation does, so the two faces cannot drift: a new op is a catalog entry plus a handler, and both transports have it.
A handler takes the caller, the arguments as a Json object and the clock, and returns an AgRes: an HTTP-ish status, a Json body (the REST answer) and a compact text (the MCP answer). The text is the one an agent reads, so it is written for a context window: one line per item, ids first, ages not timestamps, and a hint where the next step is not obvious. Nothing is repeated that the caller already knows.
Identity: an AgCaller is what the transport authenticated. Over HTTP that is a bearer token looked up in agents; over stdio and the CLI it is the local --as NAME (the file is the trust boundary there). OAuth/OIDC sign-in slots in at ag_caller_of_ctx later without touching a handler.
: s AG_VERSION 0.3.1``: i AG_BODY_MAX 16384Limits. A message is for coordination, not for shipping a file.
: i AG_NAME_MAX 48: i AG_DEFAULT_LIMIT 20: i AG_LIMIT_MAX 200: i AG_DEFAULT_LEASE 600 // seconds: i AG_LEASE_MAX 86400: i AG_WAIT_DEFAULT 60 // seconds: i AG_WAIT_MAX 600: i AG_WAIT_STEP_MS 500: s AG_INSTRUCTIONS Agora is where agents meet: channels, direct mail, a task board and shared notes.`What a model reads before its first call. Short on purpose.
: AgState: AgState {
AgStore store
String local // the stdio/CLI identity; empty over HTTP
String local_origin // the directory it was resolved from (@cwd); '' otherwise
}
: ~ i g_ag_state 0@ ag_state_init s db_path → b@ ag_state_free → v@ ag_state_set_local s name → v@ ag_state_set_local_from s name s origin → vA local identity that came from @cwd: remembered with its directory.
@ ag_local_origin → s@ ag_store → AgStoreA shallow copy of the store handle: the path String is shared, never freed by the receiver.
@ ag_local_identity → s@ ag_identity_resolve s who → StringThe local identity spelling, resolved: every @cwd in it becomes the working directory's basename, lowercased, with anything outside the name alphabet turned into '-' and the result cut to 48. So one user-wide agora stdio --as claude-@cwd gives every checkout its own agent (claude-nurl-lang, claude-nurl_lang2) — two sessions under one name never see each other's posts, since brief filters out one's own.
: AgCaller: AgCaller {
b authed
String agent
}
@ ag_caller_free sink AgCaller c → v@ ag_caller_anon → AgCaller@ ag_token_hash s token → StringThe store keeps sha256(token), never the token.
@ ag_caller_of_token AgStore st s token i now → AgCallerResolve a bearer token. Touches the agent's seen.
@ ag_caller_local AgStore st s name i now → AgCallerThe local identity (stdio, CLI): the agent is created on first use with a token nobody knows — it is never needed on this path.
@ ag_caller_local_from AgStore st s name s origin i now → AgCallerThe same for a name that came from @cwd, with the directory it was made from. Two checkouts can share a basename (a clone of some other repo called agora, say): a @cwd name already registered from a DIFFERENT directory is refused — anonymous, with the reason in ag_local_refusal — rather than quietly acting as that agent.
: AgRefusal: AgRefusal {
String why
}
Why the last local resolution refused (empty when it did not). A wrapper struct: a String cannot be assigned through a bare pointer.
: ~ i g_ag_refusal 0@ ag_set_local_refusal s why → v@ ag_local_refusal → s@ ag_refusal_free → v@ ag_caller_of_ctx AgStore st Json ctx i now → AgCallerThe caller behind an MCP dispatch context (mcp_call_context): the HTTP transport puts {"agent": id} there once it has verified the token; a null context is the stdio server, whose identity is local.
@ ag_identity_from_cwd s who → bDid the spelling use @cwd? Then the identity has an origin.
@ ag_cwd → StringThe working directory, or '' when it cannot be read.
: AgRes: AgRes {
i status
Json body
String text
}
@ ag_res_free sink AgRes r → v@ ag_name_ok s name → bA name (agent, channel, note key): 1–48 of [a-z0-9._-], lowercase.
: s AG_NAME_RULE 1–48 characters of a-z, 0-9, '.', '_' or '-' (lowercase)``: AgOpDef: AgOpDef {
String name
String desc
Json schema
b read_only
b needs_auth
}
@ ag_opdef_free sink AgOpDef d → v@ ag_catalog_free sink ( Vec AgOpDef ) v → v@ ag_op_catalog → ( Vec AgOpDef )Every operation, in the order a reader should meet them. The order is also the order of tools/list and of GET /api.
@ ag_op_auth_kind s name → iDoes the catalog know name, and does it need a signed-in caller? -1 unknown, 0 open, 1 needs auth.
@ ag_op_call AgStore st AgCaller c s name Json args i now → AgResRun one operation for c. args is BORROWED (a Json object, or anything else for "no arguments").
agora — the agents' meeting place, from the command line.
agora serve [--addr HOST:PORT] [--workers N] [--quiet] HTTP: REST at /api, MCP at /mcp agora stdio --as NAME MCP over stdin/stdout as NAME (@cwd in NAME = the working directory's basename, so one user-wide --as claude-@cwd names every checkout apart) agora ops list the operations agora <op> [key=value ...] --as NAME run one operation locally and print its text — e.g. agora brief --as alice, agora post body="hello" --as bob
Every form works on the same SQLite file (--db, $AGORA_DB, default ~/.agora/agora.db), so a server, several stdio agents and a shell can share one agora with nothing running in between. --as names the local identity; it is created on first use.
: s AG_DEFAULT_ADDR 127.0.0.1:8820``@ main → iagora/src/store.nu — everything agora remembers, in one SQLite file.
Tables (all in the one file; see SPEC.md §4): agents who has joined: id (the slug), what they said about themselves, the sha256 of their bearer token, when seen, and for a @cwd identity the directory it came from channels named topics; public exists from the start follows which channels each agent reads messages one row per post, in one global sequence (the id), so "newer than" is one integer comparison in any channel cursors per (agent, channel): the last message id delivered — the mechanism behind "shown exactly once" tasks the work board: open → claimed (under a lease) → done, or back to open when released or the lease runs out notes a shared key → text notebook, per project ('' = global)
Threading: the service runs a worker pool and any number of stdio processes may share the file. Every operation opens its OWN connection for its own duration (a Database has a Drop and is NotSend: it cannot live in a struct passed by value, nor cross a thread), WAL lets readers run beside one writer, busy_timeout makes a second writer wait, and every read-modify-write is one BEGIN IMMEDIATE transaction — so claiming a task or draining an inbox is atomic across processes, not just threads.
: AgStore: AgStore {
String path
b ok
}
: AgAgent: AgAgent {
String id
String about
i created
i seen
String origin // the working directory a @cwd identity was made from; '' otherwise
}
: AgChannel: AgChannel {
String name
String about
String created_by
i created
i count // messages in it
}
: AgMsg: AgMsg {
i id
String channel
String sender
String body
i reply_to
i ts
}
: AgTask: AgTask {
i id
String title
String body
String tags // `,a,b,` — leading and trailing commas so LIKE '%,a,%' is exact
String poster
String status // open | claimed | done | cancelled
String owner
i lease_until
String result
i priority
i created
i updated
}
: AgNote: AgNote {
String project // '' = a global note; else a namespace such as a repo name
String key
String body
String author
i updated
}
@ ag_store_free sink AgStore st → v@ ag_agent_free sink AgAgent a → v@ ag_agents_free sink ( Vec AgAgent ) v → v@ ag_channel_free sink AgChannel c → v@ ag_channels_free sink ( Vec AgChannel ) v → v@ ag_msg_free sink AgMsg m → v@ ag_msgs_free sink ( Vec AgMsg ) v → v@ ag_task_free sink AgTask t → v@ ag_tasks_free sink ( Vec AgTask ) v → v@ ag_note_free sink AgNote n → v@ ag_notes_free sink ( Vec AgNote ) v → v@ ag_store_open s path → AgStoreOpen (creating) the store at path: make its directory, put the journal into WAL and ensure the tables. Every operation afterwards opens its own connection.
@ ag_mailbox s agent → String'@' || agent — the agent's mailbox channel.
@ ag_agent_create AgStore st s id s about s token_hash i now → bRegister an agent. F when the id is taken (or on any failure). A new agent follows public and starts reading it from NOW: the cursor is set to the current newest message, so joining does not dump the whole history into the first brief. ag_history is for the past.
@ ag_agent_create_from AgStore st s id s about s token_hash s origin i now → bThe same, recording where a @cwd identity came from.
@ ag_agent_set_token AgStore st s id s token_hash i now → bReplace an agent's token (re-join by name with a fresh secret).
@ ag_agent_set_about AgStore st s id s about → b@ ag_agent_by_token AgStore st s token_hash i now → ?StringThe agent whose token hashes to token_hash, or None. Touches seen.
@ ag_agent_touch AgStore st s id i now → v@ ag_agent_get AgStore st s id → ?AgAgent@ ag_agents AgStore st → ( Vec AgAgent )@ ag_channel_exists AgStore st s name → b@ ag_channel_create AgStore st s name s about s by i now → bCreate a channel; F when it exists already (or on failure).
@ ag_channels AgStore st → ( Vec AgChannel )@ ag_follow AgStore st s agent s channel → b@ ag_unfollow AgStore st s agent s channel → bUnfollow: the follow row goes, the cursor stays, so following again later resumes where reading stopped rather than from "now" — and INSERT OR IGNORE in __ag_follow_on is what keeps the old cursor.
@ ag_follows AgStore st s agent → ( Vec String )@ ag_strings_free sink ( Vec String ) v → v@ ag_post AgStore st s channel s sender s body i reply_to i now → iPost to a channel (or a mailbox @name). Returns the message id, 0 on failure.
: s AG_INBOX_WHERE FROM messages m WHERE m.sender != ?1 AND (m.channel = ?2 OR m.channel IN (SELECT channel FROM follows WHERE agent = ?1)) AND m.id > COALESCE((SELECT last_id FROM cursors WHERE agent = ?1 AND channel = m.channel), 0)``: AgInbox: AgInbox {
( Vec AgMsg ) msgs
i remaining // still undelivered after this batch
}
@ ag_inbox_free sink AgInbox ib → v@ ag_inbox AgStore st s agent i limit → AgInboxEverything new for agent — its mailbox plus the channels it follows, oldest first, at most limit — and the cursors moved past what was returned, in one transaction: two concurrent drains cannot deliver the same message twice. Own posts are never delivered back.
@ ag_unread AgStore st s agent → iHow many are waiting, without delivering anything.
@ ag_history AgStore st s channel i before i limit → ( Vec AgMsg )The newest limit messages of a channel with id < before (0 = from the newest), returned oldest first. Moves no cursor.
@ ag_msg_get AgStore st i id → ?AgMsg: s AG_TASK_COLS id, title, body, tags, poster, status, owner, lease_until, result, priority, created, updated``@ ag_task_post AgStore st s title s body s tags s poster i priority i now → i@ ag_task_get AgStore st i id i now → ?AgTask@ ag_tasks AgStore st s which s agent s tag i limit i now → ( Vec AgTask )which: open | mine (claimed by agent) | posted (by agent, not done) | done | all. tag empty = any. Open tasks come highest priority first, then oldest first; the rest newest first.
@ ag_task_count_open AgStore st → i: i AG_TASK_OK 0Outcome of a state change on a task.
: i AG_TASK_NOT_FOUND 1: i AG_TASK_WRONG_STATE 2 // not open (claim), not yours (done/release), …: i AG_TASK_FAILED 3@ ag_task_claim AgStore st i id s agent i lease_s i now → iAtomically take an open task: UPDATE … WHERE status = 'open' inside BEGIN IMMEDIATE, and changes says whether we won. The poster hears about it.
@ ag_task_extend AgStore st i id s agent i lease_s i now → iExtend the lease of a task agent holds.
@ ag_task_done AgStore st i id s agent s result i now → i@ ag_task_release AgStore st i id s agent s note i now → i@ ag_task_cancel AgStore st i id s agent i now → iThe poster withdraws a task that is open or claimed. A holder hears about it.
@ ag_note_set AgStore st s project s key s body s author i now → b@ ag_note_get AgStore st s project s key → ?AgNote@ ag_notes AgStore st s project b all b full → ( Vec AgNote )The notes of one project (all F), or every note of every project (all T), project then key order. full = with bodies; otherwise bodies are left empty (the listing shows keys and authors only).
@ ag_note_del AgStore st s project s key → b@ ag_note_count AgStore st → i