Imported from sjawhar/legion (
packages/contracts/AGENTS.md). Install upstream withnpx skills add sjawhar/legion --skill contracts. Copyright stays with the author.
Contracts Package
Shared event contract surface for Legion/Envoy.
Overview
This package is the language-neutral source of truth for Envoy event shapes and the native Dispatch tool suite:
- envelope schema/type and subject helpers
- model-facing
dispatchToolSpecsbuilt over an injected Zod surface andzodSchemaApi - Go output in
packages/envoy/internal/contracts/generated.go - Dispatch
Actorsession values may include anownerGitHub login when a personal agent token authenticated the request; consumers render it as(for <owner>)and persist it wherever they preserve actor JSON.
Where to look
| Task | Location | Notes |
|---|---|---|
| Envelope and subject contracts | src/envelope.ts, src/subject.ts |
canonical TypeScript surface |
| Dispatch tool specifications | src/dispatch-tools.ts, src/tool-schema.ts |
builders over zodSchemaApi(hostZod) |
| Go generation | scripts/gen-go.ts |
writes the Envelope Go contract |
| Generated Go contract | packages/envoy/internal/contracts/generated.go |
generated; do not hand-edit |
| Contract tests | src/*.test.ts |
validation and schema drift coverage |
| Document block offsets and anchors | src/dispatch-api.ts |
ArtifactBlock maps stable block IDs, canonical markdown offsets, and SHA-256 full-Proof-state tokens (including inline marks), plus per-block comment/ask reference counts; Anchor.block_id is nullable for legacy rows. |
| Delivery capabilities | src/dispatch-api.ts |
DELIVERY_CAPABILITIES (aside, btw, steer) is the one closed list; MessageDeliveryMode, CommentMention, CommentDelivery, the delivery event payload schema, and the envoy-client targeted-frame schema derive from it. Comment.mentions and Comment.deliveries mirror the server's hydrated read rows; CreateCommentInput describes its matching HTTP fields. Agent.capabilities stays an open string[] on the wire. |
Critical conventions
src/tool-schema.ts—zodSchemaApi(z).string({ max })emitsis N characters over the M-character limit (L/M)(the field name is prepended byformatZodIssuesin@legion/envoy-client/tool-input-errors); the OMPpi.zodfacade ignores the message, which is why hosts register every tool withlenientArgValidationand letexecuteDispatchToolrefuse a bad call once with every problem listed.src/dispatch-tools.tsis the source of the twenty native Dispatch tools:dispatch_issue,dispatch_issue_update,dispatch_ask,dispatch_edit_ask,dispatch_resolve_ask,dispatch_resolve_comment,dispatch_follow,dispatch_comment,dispatch_suggest,dispatch_message,dispatch_doc_edit,dispatch_doc_read,dispatch_request_approval,dispatch_artifact,dispatch_read,dispatch_search,dispatch_issues,dispatch_architecture_sync,dispatch_open_asks, anddispatch_whoami. It defines their names, descriptions, and field shapes;dispatch_open_asksanddispatch_whoamihave no model-supplied session selector (dispatch_whoamitakes no arguments at all: a strict empty object;dispatch_open_askstakes only an optionalproject, which switches it from this session's own asks to every open ask in that project) anddispatch_message.in_reply_tois the same-issue message-reply correlation used for a targeted agent's answer. Host adapters consumedispatchToolSpecsdirectly.- Every Dispatch tool specification includes
example, a schema-valid call rendered with its allowed keys after the tool rejects invalid arguments; update it with any argument-shape change. dispatch_issueaccepts optional initial labels (at most 20 labels, each at most 40 characters), an optional coarse priority from0(P0, highest) through3(P3, lowest), and an optionalassignee(a GitHub login on the server's sign-in allowlist; the server lowercases it, and absent it defaults to the caller's owner, then the parent's assignee, then unassigned); an optionalcomponents(the same{mode, ids?, reason?}object asdispatch_issue_update, below); project-document arguments accept the document's artifact id, slug, or filename.dispatch-api.tscarriesIssue.assignee: string | null(also onIssueSummaryand the inbox row'sAsk.issue),CreateIssueInput.assignee?: string,UpdateIssueInput.assignee?: string | null(nullclears),DispatchUser/ListUsersResponseforGET /api/v1/users, andWhoamiResponseforGET /api/v1/whoami({kind: "user", login}or{kind: "agent", owner}).dispatch_issue_updatetakes{ issue, status?, title?, labels?, external_links?, route?, parent?, components? }with at least one field besidesissue(strict: nopriorityorrank— priority stays the human's;assigneeis not exposed either).parentis a parent issue key in the same project,""clears it (the executor maps it to a JSONnull), andUpdateIssueInput.parent?: string | nullis the wire form; the server refuses a missing, foreign-project, or self parent with400 PARENT_INPUTand a cycle with409 PARENT_INPUT. TheDispatchEventunion carrieschild.added/child.removed(ChildEventPayload {child_key}) besidechild.status, andIssueChildrows carrysubtree_done,subtree_total,active_at, andexternal_links.statusisz.enum(ISSUE_STATUSES), the exported TypeScript copy of the Go server'smodel.IssueStatuses(legion-daemon-api.ts, the daemon'slegion-state.ts, pi-envoy'slegion/tools.ts, and the SPA'sboard-model.tsthrough the runtime-only@legion/contracts/dispatch-toolssub-path import it with itsIssueStatustype; never spell the list again).external_linksare URLs the executor merges into the issue's existing links by URL before thePATCH, because the server replaces the whole set; the server answers409 EXTERNAL_LINK_TAKENwhen a URL already belongs to another issue.componentsis the issue's own architecture attachment,IssueComponentsInput {mode: "inherit" | "explicit" | "none", ids?, reason?}:inheritdeletes the row so the issue takes its nearest ancestor's attachment again (the default;UpdateIssueInput.componentsalso acceptsnull),explicitreplaces it with bare component ids of the issue's project that are notexternal,nonerecords a non-blankreasonthe issue is not architectural; the server refuses an unknown, retired, external, or other-project id with400 COMPONENTS_INPUTnaming it, and accepts the field on a closed issue without reopening it. EveryIssueandIssueSummarycarries the effectivecomponents: IssueComponents {mode, ids, unknown, reason, inherited_from}, resolved on read up the parent chain:idsare the live effective component ids,unknownthe effective ids a re-import retired (kept, never dropped),inherited_fromthe ancestor whose row applies.ArchitectureTreeisGET /api/v1/projects/{key}/architecture:source,totals, per-componentdone/total/own_done/own_totalwith issue rows (attached: "direct" | "inherited" | "contained",via), and theunassigned,not_architectural, andretired_linkslists.GraphEdgeKindgainspart_of,depends_on, andaffects;GraphNode.kindgainscomponent(id<project>/<component id>, refdispatch://<PROJECT>/component/<id>).dispatch_askhas nokindargument andAskKindis"question" | "approval": every client-created ask is a question whose asker chooses its options, none of which the server treats specially (a human to-do is the to-do phrased as the question, with whatever options fit it);approvalremains server-created throughdispatch_request_approval.dispatch_comment.turn("agent" | "human",AskTurn) is valid only withreply_to_ask; the tool-level validation rejects it otherwise. It maps toCreateCommentInput.turn.Comment.turnis that recorded turn (null under a closed ask and off ask replies),Ask.waiting_onis the open ask's derived state on every ask read, andCommentEventPayload.ask_waiting_oncarries it on acomment.createdthat replies to an open ask.dispatch_followtakes{ ask, action: "follow" | "unfollow" }— the full ask uuid or adispatch://KEY/ask/<id>reference, no owner fields — and drivesPUT/DELETE /api/v1/asks/{id}/followers/{session}with the caller's own session in theactorbody.AskRead.followers(AskFollower {session_id, since}) lists who an ask's answer and replies reach;ask.follower_added/ask.follower_removedevents carryAskFollowerEventPayload {ask_id, session_id, by}. No tool result carries a subscription topic: write results say what the session follows (details.follows.ask) and name theenvoy_subscribeline for the whole owner.dispatch_readends every render (issue, project document, ask, comment, message) withReferenced by:(edges pointing at the node — mentions and structure alike) andLinks:(edges it writes), read fromGET /api/v1/references?to=|from=(GraphReferences/GraphEdge/GraphNode/GraphExcerptindispatch-api.ts;GraphEdgeKindnames the seven edge types). Each row is- <edge kind> <node kind> <dispatch:// ref> (<excerpt> · <created_at>); a graph the server cannot serve degrades to one- unavailablerow like the closure section. The description keeps the phrase "artifact id, slug, or filename" thatdispatch-tools.test.tspins.dispatch_resolve_commenttakes{ comment }— a comment uuid, or adispatch://KEY/comment/<id>/dispatch://PROJECT/artifact/<slug>/comment/<id>reference whose id may be an 8+ character prefix unique on its owner — and drivesPOST /api/v1/comments/{id}/resolvewith the caller's session as theactorbody (the only field the server reads; there is no reason). The server lets any authenticated actor resolve any comment on an open owner;/reopenisauthHuman, so a session cannot undo a resolution.- Build field shapes through
zodSchemaApi(hostZod)so option bags apply to the host's Zod. UsedispatchToolSchema(spec, zodSchemaApi(hostZod))when the host validates a call so tool-level cross-field validation also applies. dispatch_doc_editsupportsreplace,delete,insert,retype,move,delete_row, anddelete_column.retypenames a stable block id, a server-declared type, and optional client-owned attributes;deleteandmovealso address a whole block by id; and table row or column deletion takes its table block id plus a zero-basedindex, preserving the table id and refusing to remove cells with open asks or unresolved comments. Insert/move anchors acceptblock:<id>beside quotes,start,end, andheading:<title>. An optionalpreconditionselects exactly one whole-document token fromdispatch_doc_reador block{id, token}entries from/blocks; a block guard must cover every content block the batch changes, while untouched sections remain independent. Insert and move need the document token because they depend on document order. Tokens include inline marks, so a new anchored ask or comment rejects a stale edit. A stale guard returnsPRECONDITION_FAILEDwith the current tokens and applies no part of the batch. Ask lifecycle payloads include nullableblock_id;block.repairedrestores server-owned attributes andblock.invalidrecords a malformed browser-authored ask block.- Quote anchors retain their quote display cache and inline mark while carrying nullable
block_id; new quotes use their lowest complete containing block, while quotes across top-level siblings remain unpinned. Clients must preserve the field. - Do not hand-edit
packages/envoy/internal/contracts/generated.go. - If the envelope or subject shape changes, update its schema and regenerate the applicable Go output.
- Prefer backward-compatible additions when extending the envelope.
- Dispatch
Messagecontracts preserve nullableissue_key, optionaltargetandin_reply_to, plus adeliveriesarray (empty when no attempts exist). Agent-card messages target one session without an issue and their list response isMessageRead[]; issue-targeted messages retain their issue key.message.deliveryandmessage.answeredevent payloads carry the attempt status and correlated reply that the Conversation card coalesces. A delivery mode is a capability a session advertises to the Envoy listener: add it toDELIVERY_CAPABILITIESbefore any server, adapter, or UI accepts it — never spell the list a second time. AnswerAskInput.expected_edited_atis required and carries the nullable ask revision a human reviewed. Dispatch events include project/configuration events and the two-stage subscription removal (subscription.remove_requestedwithpending: true, thensubscription.removedwith itsrequest_event_id); clients must advance their event cursor over the pending command without treating it as a completed removal.SearchResult.owneris always present: an issue owner carrieskey,title, andstatus; a standalone project-document owner carriesproject,slug,artifact_id, andname. Clients render fromowner; the optionalissueblock on issue-owned hits only mirrors the owner'skey/title/statusfor agent clients built before the owner-only shape (#1119) and goes away once no installed pi-legion-envoy / opencode-legion-envoy / claude-envoy-bridge predates it. A message hit'shrefis/issues/KEY/conversation. Ask event payloads carryoptionsas an array, nevernull.- Keep examples synchronized with the real receiver output (Slack team IDs, GitHub owner/repo segments, and native Dispatch keys).