GraphQL API
The planned context-efficient door — a published schema, Relay connections, typed userErrors and a complexity budget
Not built yet. There is no /graphql endpoint and no committed SDL in this repository. Every type
and field below is a proposed shape, taken from spec/02-agent-interfaces.md.
One endpoint, POST /graphql, with the same AgentToken authentication, the same scopes and the same
state machine as the REST door.
Planned location: apps/api/graphql.
Why both REST and GraphQL
They answer different costs, and neither is the real one.
REST is the lowest-friction door: curl, webhooks, shell scripts, any runtime, no client library.
GraphQL is the context-efficient door. An agent asks for exactly the fields it needs across the task, run, comment and artifact graph in one round trip, instead of making three REST calls whose unused fields it pays for in tokens. For a caller whose scarcest resource is context, that difference is the whole argument.
Both are planned to be generated from the same Zod and Prisma derived schemas, so they cannot drift apart.
The schema is the documentation
Schema-first and published. The SDL is committed to the repository, served at
/graphql/schema.graphql, and exposed as an MCP resource at spec://graphql-schema.
Introspection stays on for authenticated tokens. Discovery is the entire point for an agent. It is off for anonymous callers.
Every field, argument and enum value carries a description. For an agent the schema is the
documentation, so an undocumented field is an unusable field. Removals go through @deprecated with
a reason naming the replacement, never a silent drop.
Relay conventions
- A
Nodeinterface with globally unique ids, which are the ULIDs from the domain model. - Cursor
ConnectionandEdgetypes on every list. firstandafteronly. No offsets, and no unbounded lists.firstdefaults to 25 and caps at 100, matching the REST pagination limits.
Mutations return payloads, not entities
Every mutation returns a payload type rather than a bare entity:
type TaskUpdatePayload {
task: Task
event: Event
userErrors: [UserError!]!
}Expected failures — an illegal transition, a stale version, a missing acceptance criterion — arrive
as typed userErrors carrying the same code, hint and legal_transitions fields that the REST
problem+json response carries.
The top-level errors array is reserved for transport and authentication faults. A caller that has
to look in two places to find out whether its write succeeded will eventually look in the wrong one.
Idempotency and concurrency are inputs
Not headers, because GraphQL has one endpoint and one request shape.
mutation {
taskUpdate(input: {
id: "tsk_01J..."
status: IN_REVIEW
expectedVersion: 7
idempotencyKey: "b1d0…"
}) {
task { id status version }
userErrors { code hint }
}
}The semantics match REST exactly. A replay returns the original payload. A version mismatch returns
a conflict_version userError rather than overwriting.
Subscriptions
Over SSE, using graphql-sse:
subscription {
eventAdded(taskId: "tsk_01J...", since: "evt_01J...") {
id verb at payload
}
}This mirrors the REST event stream, with the same since cursor semantics, so an agent can resume
after a restart on whichever door it was already using.
Cost control
An expressive query language given to an agent needs a budget, not a hope.
- Query depth limit and a complexity budget.
- A per-token rate limit that charges by complexity rather than by request count.
- Rejections state what the budget was and what the query cost, so the next query can be made to fit rather than guessed at.
- Persisted and allowlisted operations for the hot paths a runner uses every loop:
claim,heartbeat,finish.
No N+1
Every relation resolves through DataLoader. A resolver that fans out per row fails review — it is one of the explicit gates in the agent experience checklist.
One service layer
The MCP tools are planned as thin wrappers over the same service layer, so a tool result and the equivalent GraphQL selection are byte-identical for the fields they share. An agent that moves between doors does not have to relearn the data.