Skip to content
You are reading documentation for unreleased main. Read the 0.1 version.

Authoring Your Company with an AI Assistant

A Crewlet company is described in YAML: a mission, an org chart, seats with backstories, policies, integrations. That is a lot of surface to write from a blank file — so Crewlet ships the pieces an AI assistant needs to write it with you and check its own work.

Three things make this work, and you can use any of them on their own:

PieceWhat it gives you
crewlet schemaJSON Schema generated from the models — the authoritative field list, for your editor, your CI, or an agent
crewlet validate -jsonMachine-readable problems with exact field paths and kinds, so a fix loop converges
The company-architect skillAn interview script, the invariants, and the write → validate → fix loop

End to end, from nothing to a running company. The quickstart builds the same shape by hand if you’d rather see every field explained first.

Claude Code discovers skills on disk. From a checkout:

Terminal window
mkdir -p ~/.claude/skills && cp -r skills/company-architect ~/.claude/skills/

No checkout, or a different assistant? The skill is one self-contained markdown file with absolute links — fetch it and paste or attach it. See Installing the skill for per-tool detail.

Set up a Crewlet company for a small dev-tools startup — a CEO, a CTO with two engineers, and a PM.

It won’t dump YAML at you first. It asks what the company does, who’s on it, where you sit in the chart, which surfaces the work lives on, and what model and budget to use. Give rough answers — it proposes a concrete org chart and you correct it. It should also, without being asked:

  • put you in the chart as a human seat managing the top agent
  • keep every secret as a ${VAR}, never a literal
  • skip integrations on the first pass (step 8 adds them)
  • warn you that handles are effectively permanent

You get company.yaml (Tier B — the company) and crewlet.yaml (Tier A — the infrastructure). After every edit it runs:

Terminal window
crewlet validate company.yaml -json

and fixes each reported path until valid is true. It should tell you which rung of the validation ladder it used — full crewlet fidelity, a plain JSON Schema validator, or reading the schema. None of this needs your API keys, so the whole design pass happens before you set up a single account.

The config references variables; you supply the values. Ask the assistant to list every ${VAR} it used — a missing one resolves to an empty string and fails later, deep in a turn.

Terminal window
# nothing to bring up: the engine embeds its stream and creates its store
export CREWLET_API_TOKEN_FOUNDER="$(openssl rand -hex 32)"
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..." # embeddings
export MATTERMOST_FOUNDER_USERNAME="you" # your chat username
Terminal window
crewlet run crewlet.yaml -company company.yaml

Open http://localhost:8000/. A company with no integrations has no inbound work, which is why the first pass puts a five-minute schedule on the top seat purely to prove the loop:

schedules:
- name: hello-crewlet
cron: "*/5 * * * *"
task: "Write a short note on what the company should focus on this week."

Within five minutes the CEO goes Working, steps through Plan → Execute → Review, and returns to Idle — every prompt and tool call inspectable. That’s the engine, your model, and your config all confirmed working, with no external account involved. Delete the schedule once real work arrives.

Now go back to the assistant:

Add Mattermost so the team can talk in channels and DM me.

Pick your stack in Choosing your stack, create the accounts it names (the assistant writes config, it can’t stand up a Mattermost server or create a Slack app for you), then let it wire the config and re-validate. One integration per pass — a failure is then unambiguous.

Tier B is live-editable, so applying a change is crewlet config import company.yaml, no restart. A full worked seven-seat company, with every setting’s reasoning in a comment: examples/nimbus.company.yaml — the full stack, on a tracker, a wiki and a code host. Its sibling examples/nimbus-claude-cli.company.yaml is the same company with chat on Mattermost and nothing else, which is the shape one of these passes should land on before it adds the next integration.


Why not just point an assistant at the docs

Section titled “Why not just point an assistant at the docs”

Two reasons the raw docs aren’t enough on their own.

The surface is large and strict. Tier B validates against the typed config models models that forbid unknown keys, so a plausible-but-invented field name (responsibilites, leed, commnad) is a hard error, not a silent no-op. An assistant working from prose will occasionally invent one.

The expensive mistakes aren’t type errors. Choosing a handle you later rename, putting a secret in the config file, or confusing a unit’s space (routing identity) with knowledge.scope (read scope) all produce a valid config that behaves wrong. Those are judgement calls, and they’re what the skill front-loads.

The schema fixes the first problem. The skill fixes the second.


Config authoring naturally happens before installation — you design the company, then set up the infrastructure to run it. So the schema is deliberately a static file with no dependency on the crewlet binary: an assistant fetches it from a URL and validates against it with any standards-compliant JSON Schema validator (jsonschema, ajv), or by reading it.

It carries more than field names. Because it is generated from the same Go types the engine parses with, and those forbid unknown keys, a schema-only check catches:

  • unknown keys, at every level including roles, units, and MCP servers
  • wrong shapes — a list where a map belongs, text where a number belongs — and bad enums (kind: robot, type: openaii)
  • malformed handles and unit ids, and numbers out of range

It also says where a credential goes: every field, list member or map value that holds one carries "x-crewlet-secret": true. That is an annotation rather than a check — a literal there still validates — so it is the assistant that reads it and writes a ${VAR} reference instead of a value, without guessing from a field’s name. See Credential positions.

It does not flag a number or a boolean written into a text field: YAML reads name: 2024 as a number and org_webhook: false as a boolean, the engine takes each as the text it was written as, and the schema does too — an editor that underlined a config the engine runs would teach its author to ignore it. An empty value (~ or "") is accepted wherever the engine reads it as unset.

Everything that turns on more than one field, or on what a value means, still needs the binary, and the skill tells the assistant to check it by reading:

GapWhy the schema can’t
lead / manages naming a role or unit that existsReference integrity across the document — not expressible in JSON Schema
A human seat with at least one contact identity; a knowledge.* scope naming a backend the config configuresRules that turn on whether a block is present at all, which JSON Schema can state only by enumerating every shape the block can take — and one stated subtly wrong would flag configs the engine runs
Real IANA timezoneNeeds the timezone database
A cron expression — its field count as well as its values (99 * * * *)The schema takes a cron as text; checking one needs a cron parser, which crewlet validate runs

The two encodings are held in sync by internal/config/schema_test.go, which runs every rule through both paths and fails if they disagree — a schema that quietly diverges from the loader would be worse than no schema, because an assistant would trust it.


The property that makes automated authoring safe:

Terminal window
crewlet validate company.yaml -json
{
"valid": false,
"tier": "company",
"file": "company.yaml",
"problems": [
{ "path": "roles[1].llm", "segments": ["roles", 1, "llm"],
"kind": "unknown_value", "seat": "cto",
"message": "roles[1].llm: value not in the allowed set: \"nonexistent\" is not a configured provider: providers.llm has primary. A key that misses is not an error at run time: the seat falls back to another model and bills against it, so this is the only place the typo can be seen" },
{ "path": "units[0].roles[0].name", "segments": ["units", 0, "roles", 0, "name"],
"kind": "conflict", "seat": "software-engineer",
"message": "duplicate seat name \"Software Engineer\": 2 seats carry it (handle \"software-engineer\" in unit \"Engineering\"; handle \"software-engineer-2\" in unit \"Engineering\"). A unit's lead and every manages entry name exactly one seat, and resolve to the first seat of that name, so give each of these seats its own name" },
{ "path": "units[0].roles[1].name", "segments": ["units", 0, "roles", 1, "name"],
"kind": "conflict", "seat": "software-engineer-2",
"message": "duplicate seat name \"Software Engineer\": 2 seats carry it (handle \"software-engineer\" in unit \"Engineering\"; handle \"software-engineer-2\" in unit \"Engineering\"). A unit's lead and every manages entry name exactly one seat, and resolve to the first seat of that name, so give each of these seats its own name" }
],
"warnings": [
{ "kind": "dangling_reference", "ref": "lead",
"path": "units[0].lead", "segments": ["units", 0, "lead"],
"seat": "", "unit": "Engineering", "from": "Engineering", "to": "Tech Lead",
"message": "unit \"Engineering\" names lead \"Tech Lead\", which is no seat, so the unit and every descendant inheriting its lead run with no lead. Correct the lead or add a seat with that name" }
]
}

Every offending field, with its exact path, all at once, so an assistant fixes them in one pass instead of re-guessing. A rule broken inside a seat or a unit names that seat’s handle or that unit as well, and a seat is located where it was written even when its unit: reference moves it into a unit.

A document the parser refuses (an unknown key, a value of the wrong shape) reports every such key at once, at the key as it sits in the file and with the line it is on. The rules above run once it parses:

{ "path": "roles[0].backstroy", "segments": ["roles", 0, "backstroy"],
"kind": "unknown_field", "line": 6,
"message": "roles[0].backstroy: unknown field: \"backstroy\" is not a setting: check the spelling, or the block it belongs under (line 6)" }

Each problem carries:

FieldWhat it is
pathWhere it is, as the document spells it. Empty only for a failure that belongs to no place in the document, such as a file that is not YAML at all.
segmentsThe same place taken apart: strings for keys, numbers for list indexes. A map key can contain a dot (a provider called claude-3.5), so read these rather than splitting path. null when path is empty.
kindOne of missing, out_of_range, conflict, shape, unknown_field, unknown_value, or invalid for anything this build does not classify.
messageThe whole line, exactly as the prose output prints it.
seat, unitThe handle of the seat, or the name of the unit, the problem is about. Omitted when it is about neither.
lineThe line in the file, for a problem the parser found. Omitted otherwise.

kind is a closed set with a fallback deliberately: a loop branching on it must never receive an empty string and read it as a field somebody forgot to populate. Two seats sharing a name are one message and one problem per seat, each at the name that seat wrote, so problems can hold more entries than the prose output has lines (which leads such a message with every path it applies to).

warnings are references that resolve to nothing: a unit lead, a root seat’s unit, a manages entry, or a GitLab access level naming no seat. The engine runs a company with one (live configuration assembles an organization in pieces), so a warning never fails validation, but one that survives a finished document is a misspelling nothing else will report. kind is dangling_reference, ref says which kind of reference, from and to are what holds it and what it names, and path, segments, seat and unit locate it as they do a problem. Both lists are always arrays, empty rather than absent.

Exit code is 0 when valid and 1 otherwise, in both output modes, so crewlet validate company.yaml -json || exit 1 actually gates. Nothing is echoed on stderr in -json mode: the payload already carries every problem, and a second copy is what makes the loop’s log unreadable.

It needs no credentials, no database, and no network. Tier B stores ${VAR} references verbatim and resolves them at engine start (see Configuration § Environment variables), so a complete config validates before any secret exists. You can draft and check an entire company offline.

Validation is deep: it builds the Organization, so duplicate seat and unit names, bad cron expressions, invalid timezones, human seats with no contact identity, and a knowledge scope with no backend behind it all fail here rather than at run time, and a unit lead naming no seat is reported as a warning.

-tier auto (the default) picks the tier from the document’s keys, not its filename: the one thing this has to get right is the case where the file was named something else. The two tiers share no top-level key: name / roles / units / providers / integrations mean Tier B, node / stream / store / coordination / api mean Tier A.

A document carrying neither, or an equal count of both, is refused naming the flag rather than guessed at: guessing wrong reports every field of the file as invalid, and a fix loop reading that has no way to tell it from a genuinely broken document. Force it with -tier company / -tier bootstrap.


The same schema drives your editor. Add a modeline to the top of the file — with the YAML Language Server (built into VS Code’s YAML extension, and available in Neovim, JetBrains, and Helix) you get completion, inline docs from the field docstrings, and typo squiggles as you type:

# yaml-language-server: $schema=https://docs.crewlet.ai/schema/company.schema.json
name: "Acme AI"

Or generate it locally and point at the file:

Terminal window
crewlet schema company -o schema/company.schema.json
crewlet schema bootstrap -o schema/bootstrap.schema.json

Both are also checked into schema/ in the repo. They are generated artifacts — a test regenerates and compares them, so they cannot drift from what the loader accepts.

Wire it into CI to catch a bad config before it reaches the engine:

Terminal window
crewlet validate company.yaml -json || exit 1

skills/company-architect/SKILL.md is a prompt for an AI assistant. It carries the interview script, the invariants that the schema can’t express, and the validation loop above.

It is written provider-neutral (it’s a markdown file, not a vendor format), so it works anywhere you can give an assistant instructions.

Claude Code discovers skills automatically. Install it for one project or for every project:

Terminal window
# this project only
mkdir -p .claude/skills && cp -r skills/company-architect .claude/skills/
# every project
mkdir -p ~/.claude/skills && cp -r skills/company-architect ~/.claude/skills/

Then just say what you want:

Set up a Crewlet company for a 5-person dev-tools startup.

Any other assistant — Cursor, Copilot, Codex, the ChatGPT/Claude web apps: paste the file, attach it, or add it to your rules/context directory. If you installed a released binary and don’t have a repo checkout, fetch it from GitHub.

  • Interview you about the company before writing anything.
  • Start you integration-free with one scheduled task, so you see a real agent turn within minutes — then layer integrations one at a time.
  • Keep every secret as a ${VAR} reference, never a literal.
  • Put you in the org chart as a human seat.
  • Warn you that handles are effectively permanent (see below).
  • Validate after every edit, and not claim success until it passes.

It writes config; it doesn’t provision. Standing up the Mattermost server (or the Slack workspace), the Atlassian site, the GitLab group, and the API keys is still your job — Choosing your stack lists what you must create by hand for each. Once those exist, crewlet mattermost provision and crewlet gitlab provision mint the per-seat accounts and tokens into the ${VAR} references the config already declares; Atlassian and GitHub issue no credential on a provisioner’s behalf, so their commands report which account each hand-created credential turned out to be.


Handles are effectively permanent. An agent’s durable id is a UUIDv5 over "<company name>:<handle>" (org.DeriveAgentID), so renaming a seat’s handle, or the company name, mints a new id and orphans that agent’s diary, onboarding markers, and counterparty profiles. The seat keeps working, but it has lost its memory.

Settle the company name and each handle before the company runs. See Agent Runtime § Seat Definition and the Runner.


Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.