AGWP v1 — the AgentGit Wire Protocol¶
AGWP is how clients (the CLI, SDKs, agents, execution-plane workers, the Web UI) talk to ag-server.
It is defined once, in proto/agwp/v1/agwp.proto, and served over
two transports that share one implementation:
| Transport | For | Notes |
|---|---|---|
| gRPC (HTTP/2, TLS optional) | CLI, SDKs, agents, workers | streaming uploads/downloads/events; deadlines; structured error details |
| HTTP/JSON + SSE | browsers, scripts, any language | the same messages as JSON; described by openapi.json |
Both are served on one port. GET /openapi.json returns the live OpenAPI 3.1 document; the copy in this
directory is checked by a test, so it cannot drift from the server.
Connecting¶
GET /v1/capabilities(gRPC:ProtocolService.Negotiate) needs no credentials. It returns the protocol version (agwp.v1), the features the server supports, its limits and accepted authentication methods. A client that only speaks another version getsfailed_precondition.- Every other call carries a bearer credential:
Authorization: Bearer <token>(gRPC metadata:authorization). See Authentication. GET /v1/whoamireturns who the server thinks you are, your roles and the scope of your credential.
Conventions¶
- JSON mapping. Field names are the proto field names (
snake_case), with one family of exceptions: a field that carries a JSON document inside a string in the proto (Event.payload_json,Blocker.detail_json,Job.result_json,AuditEntry.details_json,CompleteJobRequest.result_json) is exposed as real JSON under the name without the suffix (payload,detail,result,details).int64/uint64are JSON numbers (timestamps are Unix milliseconds in*_ms, sequence numbers and versions stay far below 2^53).bytesare standard base64. Enumerations are strings with a documented value set; clients must tolerate unknown values. Missing fields take their zero value; unknown fields are ignored (so old clients and new servers interoperate). - Where parameters go.
GET/DELETE: path placeholders and the query string. Other methods: a JSON object body (path placeholders are merged in and win). Free-form names — streams, revisions, paths, pin names — travel in the query string or body, never inside the URL path, so URL encoding cannot change which resource a request addresses. Repositories areowner/name:/v1/repos/{owner}/{name}/…. List-valued fields of aGETare repeated keys (?features=a&features=b). Fields namedfile_typeholdregularorsymlink(writes also acceptfileas an alias ofregular); thekindof a directory entry or of a resolved path isfile,symlink,dir(ormissing). Tree and directory listings carry each file's size in bytes. - Idempotency. Operations that create something accept
idempotency_key(HTTP: also theIdempotency-Keyheader):repo.create,objects.publish,proposal.create,integration.integrate,task.create,run.startandlease.acquire. Repeating a request with the same key and content returns the recorded response (what the first call answered — not a fresh view of the object); the same key with different content is refused withidempotency_key_reused. Fortask.create,run.startandlease.acquirethe record is written in the same transaction as the object, so two requests racing on one key, or a crash in between, never produce two objects; for these three, records are kept forrepos.idempotency_ttl_secs(default 7 days; 0 = for the life of the repository), after which the key is free again and a periodic sweep (repos.idempotency_sweep_interval_secs, default hourly) deletes the record; the engine's own keyed operations (publish, propose, integrate) are kept as operations. Every other operation either is naturally idempotent (setting a policy, granting a role, pinning, finishing a task) or cannot replay its result — a minted credential's secret is shown once, socredentials.mintignores the key; a repeatedstream.createanswersalready_exists. After a lost response to a call without a key, ask for the outcome (GET …/operations?idempotency_key=…where a key was used) instead of blindly retrying. - Pagination.
page_size(0 = default 100, max 1000) and an opaquepage_token; responses carrynext_page_token(empty = last page). Tokens are only valid for the listing that produced them. Proposal listings are keyset pages (the token carries the sort keycreated_at, idof the last row;state,streamandauthorfilter in SQL), so a walk neither skips nor repeats a proposal when others are opened or closed meanwhile. A few listings are capped instead of paged and say so withtruncated: true: credentials (newest 1000), role bindings (5000) and the lease history (limit, default 100, at most 1000; active leases are never cut). Narrow the request (subject,repo) to see the rest. Authorization itself never reads a capped list. - Deadlines. gRPC deadlines (
grpc-timeout) or, over HTTP,Request-Timeout: <milliseconds>. When a deadline expires the operation may still complete: the error isdeadline_exceeded; ask for the outcome. The server applieslimits.default_deadline_mswhen the client sets none. - Backpressure.
429 rate_limited/quota_exceededand503 unavailablecarryretry_after_ms(HTTP:Retry-After, whole seconds rounded up, at least 1, never shorter thanretry_after_ms; andRetry-After-Mswith the exact value; preferretry_after_ms). Every authenticated operation takes one token from the caller's bucket (limits.requests_per_second: per credential, or per identity for OIDC sign-ins, whose tokens are short-lived), including the ones not tied to a repository. A repository may carry a quota of its own:max_total_object_bytesis charged to every write that adds an object (uploads, workspace edits, merges — whichever endpoint writes it, atomically across concurrent requests and replicas; the tracked total is an estimate that garbage collection recounts),max_object_bytesbounds one object,max_streamsandmax_open_proposalsbound counts (counted inside the transaction that inserts the row; write transactions of one repository never overlap, so racing creates cannot pass the limit), andrequests_per_second/burstadd a per-repository rate limit. New repositories start with the[limits] repo_*/max_*defaults of the server configuration. retryable.retryable: truemeans that doing the logical operation again may succeed — after waiting (rate_limited,unavailable), after re-reading state and redoing the change (stream_moved,aborted), or, fordeadline_exceeded, after finding out whether the first attempt took effect. It does not mean that sending the same bytes again is safe: do that only for reads, for naturally idempotent operations, and for creations that carried an idempotency key. The SDKs implement exactly these rules.- Correlation. Send
X-Request-Id(gRPC metadatax-request-id); the server echoes it inX-Request-Idand in every error. Atraceparentcontinues the caller's trace (exported whentelemetry.otlp_endpointis set) and its trace id is on every log line of the request (trace_id) whether or not the trace is sampled; the peer address (remote) is on them too, failed authentications included. The server does not forwardtraceparentto anything it calls.
Errors¶
HTTP: status code + {"error": ErrorInfo}. gRPC: status + ErrorInfo in grpc-status-details-bin
(and the code in ag-error-code metadata).
{"error": {"code": "stream_moved", "message": "…", "retryable": true, "retry_after_ms": 0,
"request_id": "req_…", "details": {"stream": "main"}}}
code |
HTTP | gRPC | meaning / what to do |
|---|---|---|---|
unauthenticated |
401 | UNAUTHENTICATED | missing, invalid, expired or revoked credential |
permission_denied |
403 | PERMISSION_DENIED | authenticated but not allowed (role or credential scope) |
not_found |
404 | NOT_FOUND | also returned for repositories you may not know exist |
already_exists |
409 | ALREADY_EXISTS | |
invalid_argument |
400 | INVALID_ARGUMENT | malformed request; fix it |
failed_precondition |
412 | FAILED_PRECONDITION | state does not allow it (e.g. proposal already integrated) |
stream_moved |
409 | ABORTED | compare-and-swap lost; re-read and retry |
idempotency_key_reused |
422 | FAILED_PRECONDITION | same key, different request |
object_missing / object_corrupt |
422 | FAILED_PRECONDITION / DATA_LOSS | upload the object / bytes do not hash to the id |
too_large |
413 | OUT_OF_RANGE | |
rate_limited / quota_exceeded |
429 | RESOURCE_EXHAUSTED | wait retry_after_ms / free space or raise the quota |
unavailable |
503 | UNAVAILABLE | transient; retry with backoff |
deadline_exceeded |
504 | DEADLINE_EXCEEDED | outcome unknown; ask before retrying |
aborted |
409 | ABORTED | concurrent modification; retry |
lease_conflict |
409 | ABORTED | an exclusive lease is held by someone else |
stale_candidate |
409 | ABORTED | the candidate tree changed since the job was created |
unimplemented / internal |
501 / 500 | UNIMPLEMENTED / INTERNAL |
Routes that do not exist answer 404 not_found and methods a route does not have answer 405 (with an
Allow header); both use this envelope and carry the request id like every other error.
Outcomes are not errors. integrate returns 200 with outcome.status ∈ integrated, blocked
(something can be provided: approvals, validation, a lease), conflict, denied, invalid. Each blocker
has a stable code — see the policy guide. A conflict outcome (and a
sync that cannot merge) lists Conflicts: each has a path, a stable machine-readable kind — one of content,
both_added, delete_modify, binary, type_change, too_large, file_directory, rename_rename,
rename_delete, directory_rename_ambiguous, directory_rename_collision; new kinds may be added, so a client must tolerate one it does not know — and a message for
people (region counts, which side deleted); a client branches on kind and shows message.
The two directory_rename_* kinds come from ag-merge/3 (ADR-0013): one side
moved a directory and the other changed files under its old name in a way the merge cannot follow without guessing.
A merge that did follow a move is not a conflict; it is reported as a typed note (directory_rename) by
ag preview and in the Directory-Rename: lines of the merge changeset message. Notes are not part of AGWP v1
messages yet (Preparation carries conflicts only), so the Web UI shows the conflicts and not the notes.
Authentication¶
Three kinds of bearer credentials; all end up as an identity (user:alice, agent:refactor-bot,
service:ci) that the engine records as the actor of every change:
- Scoped tokens (
agt_…), minted withPOST /v1/credentials(orag-server admin mint): opaque, stored only as a hash, always expiring (agents: ≤ 1 h by default), revocable, and never more powerful than their minter — a credential may carry a scope (repos,streams,operationspatterns) that can only narrow, and a minted credential never outlives the credential that minted it. - OIDC ID/access tokens from configured providers (RS256/ES256/EdDSA; issuer, audience, expiry and signature are all checked; groups map to roles).
- The bootstrap token (environment variable) for first-time setup.
An agent presents its credential plus ag-run: <run_id> (gRPC metadata ag-run) to act as
agent:<name>#<run_id> — the server checks that the run exists, belongs to that agent and is running.
Credentials bound to a run (feature auth.run_credentials, ADR-0010).
A credential for agent:<name> is bound to the name by default: it may name any run of the agent in ag-run. A
scope with run_id (and exactly one literal repository in repos, the one that holds the run) binds it to one run:
- minting needs the run to exist, be
runningand belong to the subject (not_found,failed_precondition,invalid_argument), and the minter to be allowedagent.runon the repository; - the credential acts as that run - with or without
ag-run- and only as that run (naming another ispermission_denied); it cannot start runs, act on the workspaces, proposals and leases of another run, finish a task the run did not claim, or manage other runs' credentials; it can mint credentials only for its own run; - when the run is no longer
running(completed, failed, abandoned, gone) every call answersunauthenticated, open event streams end with anerrorevent, and finishing the run revokes the credentials bound to it (the check at use time is the guarantee, the revocation is the record).
Delegation (feature auth.delegation): POST /v1/credentials with a subject other than your own needs
credentials.mint (administrators: anyone but a system identity) or credentials.delegate (the delegator role:
agent:* subjects only, scoped to the capabilities the caller itself holds on every repository of the scope, never the
administrative ones; an empty operations becomes exactly that set, a request for more is permission_denied).
Authorization¶
Roles are bound to identities, patterns (agent:*) or OIDC groups, per repository pattern; a request needs
both a role that grants the capability and a credential scope that permits it.
| Role | Grants |
|---|---|
reader |
read everything in a repository: history, trees, streams, proposals, events, policy, audit pins |
writer |
reader + upload/publish, virtual workspaces, open/update/close proposals, request validation, enqueue, register agents and run as an agent (start, heartbeat and end the runs of its own agent identity only: acting for another agent needs agent.operate), tasks, leases, provenance |
reviewer |
reader + review proposals |
integrator |
reader + integrate proposals, request validation, run the queue, close proposals |
runner |
reader + claim execution jobs and submit attestations (execution-plane workers) |
delegator |
writer + mint, list and revoke credentials for agent:* subjects (never for anyone else, never with a capability it lacks) + operate other agents' tasks and runs (agent.operate: start, heartbeat or end a run for another agent, finish a task whoever owns it, reap stale runs). For a service that starts agents; replaces admin for the orchestrator |
admin |
everything, including policy, credentials, roles, GC, fsck, stream compare-and-swap |
A caller with no role at all on a repository gets not_found. Authorization says who may call which API;
the stream policy inside the engine independently decides whether a change may land (approvals,
validations, protected paths, agent autonomy) — a role never overrides it.
Objects and publication¶
Objects are content-addressed; the exact byte formats and ID computation are in object-format. A client that builds changesets itself:
POST …/objects/missingwith the ids it holds → the ids the server lacks;PUT …/objects/{id}(binary body = the object envelope; gRPC:ObjectService.Uploadstream). The server re-hashes the bytes and refuses any that do not hash toid(object_corrupt); trees cannot be uploaded;POST …/proposalswithhead(the server publishes the unpublished closure first) orPOST …/publish.
Clients without a filesystem skip all of this with virtual workspaces: POST …/workspaces, then
POST …/workspaces/{name}/changes with {path, op: put|delete, content (base64)} batches; each batch
becomes one changeset. …/sync merges the stream head in; on conflicts it reports them and changes
nothing until every conflicting path is resolved by passing resolutions.
Signing formats¶
Everything signed is Ed25519 over a domain-separated message:
message = "agentgit.sig.v1\0" ‖ len(domain):u8 ‖ domain ‖ payload domains: "provenance.v1", "attestation.v1"
- Provenance (
POST …/provenance):payloadis the record as compact JSON with keys sorted alphabetically at every level and absent optional fields asnull:agent, changeset, checks_run, created_at, inputs, model, parameters, prompt_digest, rationale, run_id, schema ("agentgit.provenance.v1"), task_id. (created_atis Unix milliseconds.) The signature is checked against the key registered for the agent, and only the agent itself may submit it. - Attestations (workers only): the canonical binary signing bytes are specified in object-format.
Events¶
GET …/events is a Server-Sent Events stream; GET …/events/replay?after_seq=N returns a JSON page.
Events are the repository's audit log: seq is strictly increasing and never reused, topic is the audit
action (proposal.create, stream.integrate, lease.acquire, run.start, policy.set, …). Resume with
Last-Event-ID or after_seq; filter with topics=a,b (prefix match on dotted names). Heartbeats are SSE
comments. A subscriber that was away loses nothing: a topic filter is applied while paging through the log,
so a long run of other entries never hides a matching event, and a replay page with fewer than limit events is
the end of the matching events up to latest_seq. The stream ends with an error event when its credential is
revoked or expires — checked before every batch is delivered, and every few seconds while idle. A query value
that is not a number (after_seq=abc, a bad Last-Event-ID) is invalid_argument, never a default.