Gates
How a build asks you a question, and how you answer it.
A gate is Composer stopping to ask you something. While one is open the build's status is awaiting_input
and pending_gate carries the question. Nothing moves until you answer it.
GET /v1/builds/{id} → status: "awaiting_input", pending_gate: { id, kind, prompt, options }
POST /v1/builds/{id}/gates/{gate_id}The gate_id comes from pending_gate.id on the build, or from a build.gate.raised event on the
stream.
The body's shape is the discriminator
Set exactly one member. Which one you set picks the answer kind; there is no kind field.
- Setting none is a
400(gate_answer_required). - Setting more than one is a
400(gate_answer_ambiguous). - An answer whose member does not match the gate's kind is a
400from the core. This API never guesses a kind.
| Gate kind | What it asks | Body |
|---|---|---|
choice | Pick one of options.choices | {"choice_id": "opt-1"} |
confirm | Yes or no | {"confirm": true} |
schema_review | Approve or edit the proposed columns | {"schema": {"approve": true, "edits": [], "notes": ""}} |
text | Anything, in prose | {"text": "…"} |
source_picker | Which source URL to build from | {"source": {"chosen_url": "…"}} or {"source": {"re_discover": true}} |
publish_review | Approve publication of the finished feed | {"publish": {"approve": true, "cadence": "…", "delivery_target": []}} |
initial_query | Confirm the refined request | {"initial_query": "…"} |
questionnaire | Several questions at once | {"questionnaire": {"answers": {"q1": "…"}}} |
204 on success.
approve must be sent, and it is never inferred
An omitted `approve` is a `400`, not a decline
On schema_review and publish_review, omitting approve is 400 approve_required; null is the same. It is
never read as false — a silent false at a publish-review gate withholds a finished feed and ends the run.
If your generated client serializes an unset boolean as false, fix the client.
SchemaColumnEdit.drop does have a default: omitted means keep the column.
{"text": "…"} answers every gate kind
Gate kinds are raised dynamically, so your client will meet a kind that did not exist when it was written. A
plain text answer is valid against every kind, including unknown ones — show the gate's prompt and send the typed
reply as text rather than failing the build.
// Correct response to a kind you have no renderer for.
{ "text": "yes, use the second source and drop the SKU column" }Answering a questionnaire
answers is keyed by Question.id. A question you are skipping must be absent, never present with an empty
string. At least one entry is required.
{ "questionnaire": { "answers": { "q_region": "EU only", "q_currency": "EUR" } } }An open question is one whose choices array is empty.
Answering a source picker
Set exactly one of chosen_url or re_discover. Both, or neither, is a 400 (source_answer_ambiguous).
The publish-review gate is the one that ships the feed
Approving publish_review opens the pull request and registers the delivery subscription. That approval is the
only route to the rows. See Limits.
A gate can expire, and expiring means answered
pending_gate.expires_at is the deadline. If it is present, not answering is a decision: at that
moment Composer answers the gate for you with its own suggested answer and the build carries on.
The gate does not fail, and the build does not stop — you simply were not the one who decided.
gate_history records it, with origin set to timeout rather than user. That is how you tell
afterwards which decisions were yours.
Windows differ per gate — a scope confirmation gets minutes, a schema review longer — so read
expires_at rather than assuming a fixed budget.
No `expires_at` means it waits for you
The field is absent when auto-proceed is off for that gate, and then the build waits indefinitely.
Turn auto-proceed off with PATCH /v1/builds/{id} if you
would rather a build stall than have a decision made for it.
Noticing a gate in the first place
Nothing calls you back — there are no webhooks on this API, and nothing in it will ever make an outbound request to you. A build stops at a gate and stays stopped indefinitely, so watching for that is your side's job, and an unanswered gate is the most common reason a build appears to have stalled.
| You are building… | Watch |
|---|---|
| A UI where someone watches the build | GET /v1/builds/{id}/events — build.gate.raised |
| A backend job that checks in periodically | GET /v1/builds/{id}, comparing version |
| A dashboard over many builds | GET /v1/builds — the summary rows carry pending_gate_id and pending_gate_kind |
GET /v1/builds answers "is anything of mine waiting on me?" across every build at once, without
fetching each one. If you poll, poll often enough that a person is not waiting on your cron.
Answering a gate that is no longer pending
409, not 400. Your JSON is fine; the build's state moved. Re-read GET /v1/builds/{id} and answer whatever is
pending now.