Configure Nimbus via the /config/* API
End-to-end recipe for bootstrapping the examples/nimbus.company.yaml company against a running engine — first the one-shot PUT /config (recommended), then per-entity edits you’d run afterwards to evolve the company live.
Every request below assumes:
export CREWLET_URL="http://localhost" # examples/nimbus.config.yaml binds the embedded API on port 80export TOKEN="$CREWLET_API_TOKEN_FOUNDER" # matches api.auth.tokens[].token in crewlet.yamlexport AUTH="Authorization: Bearer $TOKEN"/health should report {"status":"unconfigured","configured":false} before you start (still HTTP 200 — the status code is liveness, and an engine waiting for a configuration is alive). After the first PUT it flips to {"status":"ok","configured":true} and stays that way for the engine’s lifetime.
curl -s $CREWLET_URL/healthSee the Configuration concept doc for the two-tier split and the rationale behind live config management, and the API endpoints reference for status codes.
Option 1 — Single full-document PUT (recommended for bootstrap)
Section titled “Option 1 — Single full-document PUT (recommended for bootstrap)”The simplest path. Send the whole company.yaml in one request; the engine validates, persists as a new revision, appends an activation epoch, and spawns the whole company. Every node in the deployment converges on that epoch — see Control Plane.
curl -X PUT $CREWLET_URL/config \ -H "$AUTH" \ -H "Content-Type: application/yaml" \ -H "X-Summary: bootstrap Nimbus" \ --data-binary @examples/nimbus.company.yamlA revision summary is required on every write. It travels in the X-Summary
header, or as a top-level _summary key in the body:
curl -X PUT http://localhost:8080/config \ -H "Authorization: Bearer $CREWLET_API_TOKEN" \ -H "Content-Type: application/yaml" \ --data-binary $'_summary: bootstrap Nimbus\n'"$(cat nimbus.company.yaml)"The body key exists because the body is often the only thing a caller
controls — a form post, a proxy that strips unknown headers, a CI step piping
a document through a tool that takes no header arguments. It is removed
before the document is parsed, so it never trips the unknown-field check
that Tier B applies deliberately. When both are present the header wins:
it is the more explicit channel, and a _summary can survive in a document
somebody keeps in version control long after it stopped describing the write.
Response is 201 Created with the new revision_id and epoch, the
warnings the engine has about the document (a lead or a manages entry that
names nobody, for example) and the derived hierarchy it will run. See
What a write answers.
To check a document without writing it, send the same request with
?dry_run=true. Nothing is stored or activated, no summary is needed, and the
answer is 200 {"valid": true, "base_revision_id", "warnings", "derived"}, or
the refusal the write would get:
curl -X PUT "$CREWLET_URL/config?dry_run=true" \ -H "$AUTH" \ --data-binary @examples/nimbus.company.yamlA refusal names each failure in detail and again in problems, one located,
classified entry per failure with its path, segments and kind (and the
line when the parser found it), so a script can point at the field rather
than parse the message. See
Refusals carry located problems.
The body is read as YAML, which JSON is a subset of, whatever Content-Type
says.
JSON body works too:
curl -X PUT $CREWLET_URL/config \ -H "$AUTH" \ -H "Content-Type: application/json" \ -H "X-Summary: bootstrap Nimbus" \ --data-binary @nimbus.company.jsonVerify:
curl -s $CREWLET_URL/health $AUTH # configured: truecurl -s $CREWLET_URL/config -H "$AUTH" | jq '.name' # "Nimbus"curl -s $CREWLET_URL/config/revisions -H "$AUTH" | jq '.[0]' # newest firstcurl -s $CREWLET_URL/agents | jq 'length' # 7 agent seats spawnedIf anything else has touched /config since you last read it, supply If-Match:
REV=$(curl -s $CREWLET_URL/config/revisions -H "$AUTH" | jq -r '.[0].revision_id')curl -X PUT $CREWLET_URL/config \ -H "$AUTH" -H "If-Match: $REV" \ -H "Content-Type: application/yaml" \ -H "X-Summary: bootstrap Nimbus" \ --data-binary @examples/nimbus.company.yaml# 409 revision_advanced if the active revision moved past $REV between read + writeOption 2 — Evolve a live company one entity at a time
Section titled “Option 2 — Evolve a live company one entity at a time”Four collections are addressable on their own: roles, units, llm-providers and mcp-servers. Use these to change one thing about an already-active company; use Option 1 to bootstrap it, and for anything the four do not cover (the identity block, integrations, the turn engine, the knowledge scope).
PUT /config/roles/{handle}PUT /config/units/{name}PUT /config/llm-providers/{key}PUT /config/mcp-servers/{name}Why bother, when PUT /config already works? Because that write makes every
edit a company-wide one. Changing one seat’s goal means sending back a
document carrying every other seat, every provider and every integration — and
a concurrent edit anywhere in it is yours to lose. A per-entity write narrows
what you are claiming to have changed, which is what makes the revision
summary in the history mean something.
The loop
Section titled “The loop”Read the entity, edit it, send it back. The read is the same redacted
document GET /config serves, sliced:
# What the collection holdscurl -s "$CREWLET_URL/query/config_entities?kind=roles" -H "$AUTH" | jq
# One entity. The response IS the entity, so it goes straight back.curl -s -D headers.txt "$CREWLET_URL/config/roles/ceo" -H "$AUTH" > ceo.json
# Edit ceo.json, then send it back — quoting the ETag the read returned, so a# concurrent activation is refused rather than silently overwritten.curl -X PUT $CREWLET_URL/config/roles/ceo \ -H "$AUTH" -H "Content-Type: application/json" \ -H "If-Match: $(awk -F'"' '/^[Ee][Tt]ag:/ {print $2}' headers.txt)" \ -H "X-Summary: give the CEO a quarterly goal" \ -d @ceo.jsonThe config_entities query still lists a collection and still answers a
{kind, id, entity} envelope — it is what the dashboard reads. For one entity
prefer GET /config/{kind}/{id}, whose body is exactly what PUT takes.
The response is 201 Created with the new revision_id, epoch, warnings
and derived hierarchy, exactly as a full PUT would be: the write changed one
entity and created one revision.
What a write actually does
Section titled “What a write actually does”It is not a patch protocol. The engine opens the active revision, splices your entity in, restores the credential masks the read showed you against that same revision, validates the whole document, and stores the result. Three consequences worth knowing before you script against it:
-
The whole company is validated, not just your entity. A seat naming an
llmprovider that no longer exists is fine on its own and breaks the company; you get400 validation_errornaming the field. This is the point of validating whole — you never see the rest of the document, so it is the one place that break can be caught. -
An unknown field is refused, not dropped. A body carrying
gaolwhere you meantgoalis400 invalid_bodynaming the field, exactly as the whole-document parser refuses an unknown key. A decoder that ignored what it did not recognise would answer201and store a seat with no goal, and this is the surface most likely to be hand-edited in a hurry. -
A plain
PUTnever creates. An id the active revision does not carry is404 no_such_entity: naming one that is not there is far more often a typo than an intent to add one. To ADD an MCP server or an LLM provider, say so — send the samePUTwithIf-None-Match: *, the create-only condition at the new entity’s own address. It is added (an MCP server after every server already declared, so no seat’s tool block moves) and the whole company validated as for any write; a name already taken is412 entity_existsrather than a replacement of a server you never saw. Do not sendIf-Matchbeside it (400 conflicting_preconditions): the create lands on the revision active when it commits, compare-and-set. A seat and a unit are not created by address (400 not_creatable) — each has a place in the chart the path does not name — so add those throughPUT /config.Terminal window curl -X PUT http://localhost:8080/config/mcp-servers/linear \-H "Authorization: Bearer $CREWLET_TOKEN" \-H "If-None-Match: *" \-H "X-Summary: add the linear server" \-d '{"name":"linear","transport":"http","url":"https://mcp.example.com","headers":{"Authorization":"${LINEAR_TOKEN}"}}' -
The path is the identity, and a
PUTnever renames.PUT /config/roles/ceoreplaces whatever is atceo; a body carrying a different handle is400 identity_mismatchrather than a move. The handle is effectively permanent — the seat’s durable id derives from it, so a rename orphans that seat’s diary, its onboarding marker and its counterparty profiles — and nothing that references the old name travels with the splice. The check is on the derived handle, so{"name": "Chief Executive"}with nohandleis refused too: an omitted handle is derived from the name, which makes a display-name edit a rename by accident. Keephandlein the body and change whatever else you like. Renaming is a full-document edit. -
PUTis the only verb. There is noDELETE /config/roles/ceo; the path answers405. Removal is a full-document edit — deleting a seat strands its mailbox and its in-flight work, and deleting a provider silently repoints every role that named it. If that is going to happen, it should happen in a document you looked at, and land as one reviewable revision. Export, edit,PUT /config.
A seat inside a unit is reachable by handle like any other — you do not have to know which list it lives in, or how deeply the unit is nested.
A write keeps what the node’s own build cannot represent. During a rolling
upgrade a node may hold a document a newer node wrote, with settings its
GET cannot show you; whatever you send back through it, those settings
survive on every seat, unit and MCP server matched by its identity. See
Fields a newer build wrote survive every write.
X-Summary and If-Match
Section titled “X-Summary and If-Match”Both work exactly as they do on the full PUT, and X-Summary is required:
the revision history is what someone reads at 3am to find the change that
broke something, and a per-entity write is the one most likely to be made in a
hurry. A node with no active revision answers 409 no_active_revision — there
is nothing to splice into.
Read paths
Section titled “Read paths”# Active revision (JSON or YAML)curl -s $CREWLET_URL/config -H "$AUTH" | jqcurl -s "$CREWLET_URL/config?format=yaml" -H "$AUTH"
# Revision history (newest first)curl -s "$CREWLET_URL/config/revisions?limit=20&offset=0" -H "$AUTH" | jq
# Single revision incl. payloadcurl -s $CREWLET_URL/config/revisions/$REV -H "$AUTH" | jq
# Structural diff between two revisions (or against active)curl -s "$CREWLET_URL/config/revisions/$REV/diff" -H "$AUTH" | jqcurl -s "$CREWLET_URL/config/revisions/$REV/diff?against=$BASE_REV" -H "$AUTH" | jq
# Revision metadata for ops scraping (no payloads) — a query, not a REST routecurl -s "$CREWLET_URL/query/config_audit?limit=50" -H "$AUTH" | jqRevert
Section titled “Revert”Re-activate any historical revision as a new active revision (the audit chain stays intact via parent_revision_id):
curl -X POST $CREWLET_URL/config/revisions/$REV/revert \ -H "$AUTH" -H "X-Summary: revert — bootstrap was missing role X"When another system manages the document
Section titled “When another system manages the document”Everything above is a person driving the API. When something else is the
source of the company — a GitOps pipeline applying company.yaml from a
repository, or external tooling such as a Kubernetes operator rendering it from
custom resources — that system runs exactly these requests with its own token,
and it replaces whatever is active each time it reconciles. A person’s edit
made in between lasts until then, and nothing says it was lost.
Give the managing system its own token and name it as the document’s only writer in every node’s Tier A:
api: auth: tokens: - {id: gitops, token: "${CREWLET_API_TOKEN_GITOPS}"} - {id: founder, token: "${CREWLET_API_TOKEN_FOUNDER}"} company_writers: [gitops]The pipeline keeps writing as above, with Authorization: Bearer $CREWLET_API_TOKEN_GITOPS and an If-Match on the revision it last wrote, so a
person’s reload in between is a 409 it re-reads rather than one it
overwrites. Everyone else still reads — GET /config, the history, the diffs —
and is refused a change:
curl -X PATCH $CREWLET_URL/config -H "$AUTH" \ -H "Content-Type: application/merge-patch+json" -H "X-Summary: a tweak" \ -d '{"mission":"ship it"}'# 403 {"error":"config_managed","managed_by":["gitops"],"detail":"…","hint":"…"}The dashboard shows the document as managed by gitops: the org builder opens
read-only and every control that writes the document is disabled with that
sentence. What stays open is the break-glass for a leaked credential, which the
managing system cannot know about — write the new value and re-publish the
unchanged document:
printf %s "$NEW_VALUE" | curl -X PUT $CREWLET_URL/secrets/GITLAB_TOKEN -H "$AUTH" --data-binary @-curl -X POST $CREWLET_URL/config/reload -H "$AUTH" -H "X-Summary: rotate the leaked GitLab token"The full list of what is refused and what stays open, and why, is in Managed configuration.
Common error responses
Section titled “Common error responses”Every 400 about the document carries problems, the failures located and
classified, beside the detail that renders them.
| Status | Error | Meaning |
|---|---|---|
400 | invalid_body | The body is not YAML or JSON, or its shape is not a company’s (an unknown key, a list where a mapping belongs). Content-Type is not what decides it |
400 | invalid_patch | A PATCH body that could not be merged, or that names a key the document does not have |
400 | validation_error | The whole resulting document failed validation; detail carries the message and problems locates each failure |
400 | summary_required | Any write with neither an X-Summary header nor a top-level _summary key in the body |
400 | invalid_query | dry_run given as anything but true or false |
401 | invalid_token | Bearer missing / wrong / wrong scheme |
403 | config_managed | The document is managed by another system and this token is not one of api.auth.company_writers; managed_by names the writers. A write and its dry run alike |
404 | no_active_revision | Reading /config before the first PUT |
404 | no_such_entity | A per-entity PUT naming an id the active revision does not carry — this route never creates |
404 | no_route | A path under /config this surface does not serve |
405 | method_not_allowed | A /config path under a method it does not take; Allow names the ones it does |
409 | no_active_revision | A per-entity write before the first PUT: there is nothing to splice into |
409 | revision_advanced | Stale If-Match, a concurrent writer won the race, or the write was built on an empty store while the fleet is running a company |
412 | no_active_revision | If-Match: <revision> sent while the node has no active revision; retry without If-Match, or send If-None-Match: * |
412 | already_configured | If-None-Match: * sent while a revision is active on this node or anywhere in the fleet |
415 | unsupported_patch_media_type | A PATCH in a patch format other than a JSON Merge Patch, such as application/json-patch+json |
503 | draining | The node has been told to stop. Nothing was written; Retry-After says when to try again, against a peer or against this node once it has restarted — see During a drain |
The full reference is in API endpoints.
Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.