A multi-team backlog and kanban board for one person who works on many projects at once, with an LLM as a co-worker. Teams keep their tickets apart, projects group the work, tickets carry the analysis, the open decisions and the verification, and an LLM such as Claude Code operates it through the same API people use in the browser — with a personal access token that says who is accountable.
Status: phase 6 — import and cut-over — and phase 7 — hardening and 1.0 — started on 2026-10-06; phase 3 — the UI on the core domain — awaits the owner's reviews, its parts released from
0.2.0to0.7.0; phase 4 — the login through an identity provider — and phase 5 — the LLM interface and the chat in the UI — released as0.3.0; the sign-in without a click as0.8.0; metrics, the chart's references with the migration Job and GitHub's webhook as0.9.0; the consistency check after a restore as0.10.0; the import of Markdown tickets and the export as0.11.0. 1.0 comes once every open question is answered. Teams, projects and tickets — with links, state transitions, open questions, comments, interest, progress, time entries and attachments — the audit record and the event stream exist behind a JSON API, tested against PostgreSQL 18, PGSTY Silo — the maintained MinIO fork — and Dex. A person logs in through any OpenID Connect provider, whose groups decide who gets in and — mapped per team — in which role, or with a local account: the local administrator the installation's Secret names, or the provider's administrator group, creates the first team, its administrators add its people, and each person makes their own personal access tokens in the session. Claude Code works on the backlog throughcowork-mcp, an MCP server with hooks that runs on the person's machine (Claude Code); in the browser an assistant works on the same tools as the person's agent, with a model of the providers the operator lists — LM Studio on the operator's machine, a server of the OpenAI format or Anthropic — and the capabilities the person chooses for it; its acts run at once, and Stop ends a turn at once (the chat). What comes next is in the work lists of docs/tickets/, a family ticket for each open phase; 1.0 waits until every open question there is answered.
flowchart LR
B[Browser] --> G[Ingress]
B -.->|login redirects| I[OpenID Connect<br/>identity provider]
C[Claude Code] -->|stdio| X[cowork-mcp<br/>personal access token]
X -->|HTTPS| G
G -->|/| F[cowork-frontend<br/>nginx + Angular bundle]
G -->|/api/ /auth/| S
K[kubelet] -->|/healthz /readyz| S
M[migrate<br/>init container] -->|owner role| P
S[cowork-backend<br/>Go API] -->|runtime role| P[(PostgreSQL 18)]
S -->|attachments| O[(S3-compatible<br/>object storage)]
S -->|discovery, code, refresh| I
S -->|the chat's model calls| L[LLM providers<br/>OpenAI format or Anthropic]
- 🧩 Two containers, one origin — the Go backend serves the JSON API, the nginx frontend serves the Angular bundle and nothing else, and the chart's Ingress routes
/api/and/auth/to the backend and the rest to the frontend, so the browser sees one origin. - 🎫 Tickets with stable keys —
acme/COW-42: five types, a state matrix that asks for reasons and a verification note, four link types with a cycle check onblocksand the prerequisite tree they make, open questions, comments with their history, interest, progress, time entries and attachments. - 🔑 Tokens for people and agents — personal access tokens with a scope and an optional team or project restriction, made by the person in a browser session and never by a token; an agent, marked by its token or by
X-Cowork-Agent, is bound by capabilities and sends anIdempotency-Keywith every creatingPOST. - 💬 An assistant in the browser — a chat panel whose model works on the same tools as the person's agent, with the capabilities the person chooses (by default not deciding, closing, dropping or recording answers); the providers are a list in the chart, the person picks one, and Stop ends a turn at once.
- 🤖 Claude Code as a co-worker —
cowork-mcp, one static binary per platform, serves fifteen workflow tools over the API with the person's token and runs Claude Code's hooks: a session starts with its project's state and is reminded at its end; a repository finds its project by its normalised git remote, and an unbound one gets a proposal the person confirms. - 🪪 Single sign-on through any OpenID Connect provider — the code flow with PKCE against a provider discovered at start, a gate of allowed groups and an administrator group, per-team group mappings that derive memberships, marked grants beside them, the groups read again every fifteen minutes, and tokens held to the same gate; tested against a minimal Dex.
- 🔐 A login that needs no identity provider — a local administrator kept in step with a Secret, local accounts created by team administrators, Argon2id, sessions in the database behind an
HttpOnly__Host-cookie, an account lockout and an address throttle that answer every failure alike, and an origin-plus-header CSRF check on every write of a session. - 🏛️ Administration that leaves nothing behind a token — members, grants, group mappings, restricted projects and their access lists, the tokens that can act in the team and their revocation, the attachments' usage against an optional per-team quota, in the UI; every act that can give access takes a browser session, every act is recorded with the keyed hash of the client's address, and an administrator's change that would leave a team without an administrator is refused.
- 🛡️ Teams isolated twice — every query names its team, and forced row-level security under a runtime role that owns nothing backs it; the backend refuses a role that could bypass it.
- 📜 Contract first — the OpenAPI 3.1 document in
backend/api/generates the server, is served at/api/v1/openapi.jsonand validates every request; every error is RFC 9457 problem details with a stablecode. - 🧾 Every act on the record — an append-only audit record of who did what, with the token and the agent; every act made through a token shows it on the ticket — the agent's mark, or the token's name — so nothing a script or a model does reads as the person's own;
ETagandIf-Matchkeep two writers from overwriting each other. - 📡 Live updates — server-sent events per team carry keys and versions, never content, filtered by what the reader may see; a reconnect replays what it missed.
- 📈 Prometheus metrics — requests by route pattern, the database pool, the jobs, the event streams, the acts, the logins, the schema and each team's attachments out of step with the bucket, on a port of their own that no Service carries and no label of which names a person, a team only by its id; the chart adds a
PodMonitoror aServiceMonitor, five alerts with their runbooks and a Grafana dashboard, each behind a switch. - 🩹 A restore that cannot go silently wrong — backups are the database's and the object store's; cowork checks every team's files against the bucket once a day, shows its administrators the files whose bytes are missing and the objects no file names, lets them accept a loss or remove the leftovers in a browser session, and
cowork check-consistencyruns the check at once after a restore. - 🗑️ Deletion that waits thirty days — a team administrator deletes a ticket, never an agent; from then on it answers like a missing one everywhere but the team's bin, which restores it as it was, until the purge — a job thirty days later, or an administrator's second confirmation in a browser session, which no token gives — removes it with its files, keeping in the audit record only its key, who did what and when.
- 🔖 Saved filters — the list filters under a name, the person's own or shared with the team with its owner beside it, applied, saved and shared from the filter bars of the backlog, of the team board and of the team's ticket list — every project's tickets in one table, whose address is its filter, so a filtered list is a link; a value that no longer holds is a warning, a shared filter that names what the reader cannot see is shown without its conditions, and a team administrator unshares or deletes a shared one — of a person who left, say — and changes nothing else of it.
- 🔎 Search with snippets — PostgreSQL full text over titles, bodies, comments, questions and file names, keys by their beginning and titles by trigram, one ranked hit per ticket with the words found marked; a team's from its pages, every team's of the person from anywhere, each hit held to what the reader may see.
- 📝 Markdown rendered on the server — the body, comments, options and answers rendered with goldmark and held to an allow-list by bluemonday: raw HTML shown as text, links with
rel="noopener noreferrer nofollow", images only of the ticket's own raster attachments; Angular's sanitiser runs over it again. - 📦 Import and export — a repository's Markdown tickets, open and archived, or an earlier export come into a project through a dry run whose report names every file's outcome, type, state and warnings for any writer of the project, or their agent, to read and correct, on the project's import page, with
cowork-mcp importor through the API, then one transaction that imports every file it can — keeping their numbers — and leaves out each one with an error or a conflict, saying why; a project or a whole team leaves as an archive of the tickets' Markdown with manifests of their links and attachments, fetched by anyone who reads it — from the project's header, the team's settings or the API — the backup's second line, and the round trip a test. - 🔔 An inbox and the lists across teams — a notification for an assignment, a mention in a comment (
@picks the person), a question asked of you, your question answered, a state change or a comment on a ticket you watch, a blocker closed and an urgent need, written with the act and shown from it; a bell with the unread count, live; and "next for me" — the start page —, "assigned to me" and "open decisions" across every team of the person, each item beside its team. - 🧭 Every team in the sidebar — every team of the person with its projects, on every page, the person-level lists above them; a team's name opens its dashboard, the gear beside it one dialog of the team's configuration — members, accounts, group mappings, tokens, settings, audit record, deleted tickets — each at its own address; a global administrator finds every team of the installation, and makes a new one, under "All teams".
- 📊 A dashboard per team — its front page, with the team's board, ticket list and time report as tabs: nine fixed tiles — open tickets by state and by severity, open security findings, the blocked, their age, done per week, lead time, open decisions, time booked — filtered by project and period, counted only over what the reader may see, and live.
- 🎯 Rank is the decision, score is the warning — each project's backlog is ranked by hand, grouped by horizon; a versioned score of severity, horizon, stakes and age marks where it disagrees, can be adopted in one recorded act, and orders the lists across teams.
- 🗄️ Migrations under their own role — embedded SQL applied by an init container as the owner role, serialised across replicas by an advisory lock; the serving container holds only the runtime credential and refuses a schema with pending migrations.
- 🐘 PostgreSQL 18 and S3 —
uuidv7()keys and full-text search in PostgreSQL; attachments in any S3-compatible bucket, served only through the backend. - ⎈ One Helm chart — two hardened Deployments, an Ingress that routes the API to the backend, every credential from an existing Secret, no RBAC because neither container talks to the Kubernetes API, and no NetworkPolicy, because network policies are the cluster's.
- 🧪 Tested in every layer — Go unit tests; integration and API tests against PostgreSQL 18 — one of them serving TLS under a private authority —, Silo and Dex — and an issuer in the test's own process for what Dex cannot be made to do — with every response checked against the API document; Angular unit tests, chart lint and render, one container scan per image, release tooling check; all as
maketargets CI runs unchanged. - 🆕 Newest toolchains — Go 1.27 and Angular 22, moved by Renovate as grouped updates.
- 🗂️ Documentation with five homes — decisions in ADRs, work lists in tickets that get archived, one security page per perspective.
- 🧭 Decided, then built — every founding question was put to the owner one at a time and became an ADR before the code that depends on it.
Every backend setting is COWORK_<NAME>; the full table is under Configuration.
A database role given as components instead of a URL is COWORK_DATABASE_HOST, _PORT, _NAME,
_USER, _PASSWORD and _SSLMODE, the owner role's the same under COWORK_DATABASE_OWNER_.
The frontend container takes no variable: its nginx configuration is a file in the image
(frontend container). The integration tier reads
COWORK_TEST_DATABASE_URL, COWORK_TEST_DATABASE_TLS_URL, COWORK_TEST_DATABASE_TLS_CA,
COWORK_TEST_S3_ENDPOINT, _ACCESS_KEY_ID, _SECRET_ACCESS_KEY and COWORK_TEST_OIDC_ISSUER,
every one of them required; make dev-seed reads
COWORK_DEV_SEED_DATABASE_URL; make dev takes COWORK_DEV_ADMIN and
COWORK_DEV_ADMIN_PASSWORD. The development containers take CONTAINER_BIND (127.0.0.1
# default), POSTGRES_PORT, POSTGRES_TLS_PORT, MINIO_PORT and DEX_PORT from make. cowork-mcp reads
COWORK_URL, COWORK_TOKEN and CLAUDE_PROJECT_DIR (CLI (cowork-mcp)); the
plugin's hooks hand it CLAUDE_PLUGIN_OPTION_COWORK_URL and CLAUDE_PLUGIN_OPTION_COWORK_TOKEN
under those names. A chat provider's variables are COWORK_CHAT_<ID>_NAME, _KIND, _URL,
_MODEL and _API_KEY, the id of COWORK_CHAT_PROVIDERS upper-cased with its dashes as
underscores — claude-work is COWORK_CHAT_CLAUDE_WORK_URL (the chat);
make dev takes COWORK_DEV_CHAT_MODEL.
| Object | Name | Notes |
|---|---|---|
| Backend Deployment and Service | <fullname>-backend |
<fullname> is <release>-cowork, or the release name itself when it contains cowork; fullnameOverride replaces it |
| Frontend Deployment and Service | <fullname>-frontend |
the Ingress sends every path but /api/ and /auth/ here |
| Ingress | <fullname> |
only with ingress.enabled; per host /api/ and /auth/ (Prefix) to <fullname>-backend, / (Prefix) to <fullname>-frontend |
| ServiceAccount | <fullname> |
shared by both pods, no token mounted |
| Init container of the backend pod | migrate |
runs cowork migrate as the owner role; only with migrations.mode: onStart (the default) and backend.config.migrateOnStart |
| Migration Job | <fullname>-migrate, component migrate |
only with migrations.mode: job: a Helm hook (pre-install,pre-upgrade, weight -10, deleted before its next creation and once it succeeded) that runs cowork migrate with COWORK_MIGRATE_BOOTSTRAP=true, as the namespace's default ServiceAccount (or serviceAccount.name without serviceAccount.create), no token mounted |
| Database Secret rendered by the chart | <fullname>-database, key databaseUrl |
only with database.url; never in job mode |
| Owner database Secret rendered by the chart | <fullname>-database-owner, key databaseUrl |
only with database.owner.url while the init container migrates; never in job mode |
| Local administrator Secret rendered by the chart | <fullname>-local-admin, keys username and password |
only with the inline localAdmin.username and localAdmin.password |
| Identity provider's client Secret | not rendered: auth.oidc.existingSecret names one of yours, key auth.oidc.keys.clientSecret (clientSecret) and, when set, auth.oidc.keys.clientId |
only with auth.oidc.issuer; the client secret has no inline value |
| A chat provider's key Secret | not rendered: each entry of chat.providers names one of yours in existingSecret, key keys.apiKey (apiKey) |
one per provider; required for kind anthropic; there is no inline value |
| Pod annotation of the backend | cowork/inline-credentials-revision, the release's revision |
only while an inline credential is set — database.url, database.owner.url while the init container migrates, or localAdmin.username with .password; every upgrade rolls the pods, so a changed value reaches them, and no hash of a credential is in the pod template. Under helm template and Argo CD the revision stays 1 and a changed value takes a rollout restart |
| CA volume of the backend | s3-ca, mounted at /etc/cowork/s3-ca |
only with the storage on (storage.endpoint or storage.existingConfigMap) and storage.tls.caConfigMap |
| Database CA volume | database-ca, mounted at /etc/cowork/database-ca into the migrate init container, the serving container and the migration Job, COWORK_DATABASE_CA=/etc/cowork/database-ca/<keys.ca> |
only with database.tls.caConfigMap |
| Headless metrics Service | <fullname>-backend-metrics, port metrics, component label metrics |
only with metrics.serviceMonitor.enabled; the metrics port alone, so the backend Service never carries it |
PodMonitor, ServiceMonitor, PrometheusRule |
<fullname> |
each only with its switch under metrics.*; they need the Prometheus Operator's CRDs |
| Grafana dashboard ConfigMap | <fullname>-dashboard, key cowork.json, label grafana_dashboard: "1" # default |
only with metrics.grafanaDashboard.enabled |
| nginx exporter sidecar of the frontend pod | container nginx-exporter |
only with frontend.metrics.exporter.enabled |
| Labels | app.kubernetes.io/name=cowork, app.kubernetes.io/instance=<release>, app.kubernetes.io/component=backend|frontend|migrate|metrics, app.kubernetes.io/version, app.kubernetes.io/managed-by=Helm, helm.sh/chart |
selectors use name, instance and component; migrate is the migration Job's own, metrics the headless metrics Service's |
| Container ports | http, 8080 on both containers; metrics, 8081, on the backend; nginx-metrics, 9113, on the exporter sidecar |
backend.containerPort, metrics.port, frontend.metrics.exporter.port; the frontend's is fixed by the nginx configuration, which also listens on 127.0.0.1:8082 for stub_status, inside the pod only |
| Thing | Pattern | Example |
|---|---|---|
| Team slug | 2–63 characters of a-z, 0-9 and -, not starting with - |
acme |
| Project key | 2–10 characters: an upper-case letter, then A-Z and 0-9; no hyphen; never reused in its team |
COW |
| Ticket key | <team-slug>/<PROJECT>-<number>; the number counts per project from 1, is never reused and ends at 2147483647 |
acme/COW-42 |
| Short ticket key | <PROJECT>-<number>, where the path fixes the team |
COW-42 |
| Question | its number within the ticket, from 1 | …/tickets/42/questions/1 |
| Every other id | a UUIDv7 | 0199a3c2-1d2e-7f00-8000-000000000001 |
| Personal access token | cwk_ and 43 base62 characters; cowork stores its SHA-256 only |
— |
| Session cookie | __Host-cowork-session, 43 base64url characters (256 random bits); cowork stores its SHA-256 only |
HttpOnly; Secure; SameSite=Lax; Path=/, no Domain |
| Login state cookie | __Host-cowork-oidc: the state, the nonce and the PKCE verifier of a login through the identity provider, the path to return to, the time and whether the login is silent, sealed with AES-256-GCM; cleared by the callback |
HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600, no Domain |
| Username | 1–63 characters of a-z, 0-9, ., _ and -, starting with a letter or a digit; unique in the installation; the identity is local:<username>, which POST …/members takes as well |
ada.lovelace |
| Person of the identity provider | the issuer and the ID token's sub; no username |
— |
| Group name | as the provider's groups claim carries it, matched exactly, case and all; in a mapping 1–256 characters with no white space at either end | cowork-users # example |
| System actor | system:<name> in an audit row: login, bootstrap, identity-provider, and the jobs idempotency-expiry, session-expiry, login-expiry, notification-expiry, ticket-purge, consistency-check, import-expiry |
system:identity-provider |
| Local account origin | config — the one account COWORK_LOCAL_ADMIN_* names — or tenant — one a team administrator created and that team manages; the stored value keeps the name a team had before |
— |
| Agent header | X-Cowork-Agent: <name>/<model>/<session>, each part 1–64 printable ASCII characters |
claude-code/opus/7f3a |
| Agent header of the chat | chat/<model>/<conversation>: the picked provider's model, its / written :, and the conversation's id the browser made |
chat/qwen:qwen3-30b-a3b-2507/0199a3c2-1d2e-7f00-8000-000000000042 |
| Chat provider id | 1–32 characters of a-z, 0-9 and -, a dash neither first nor last; named once in COWORK_CHAT_PROVIDERS |
lmstudio, claude-work |
| The chat's browser storage | localStorage: cowork.chat.<person id> (open or closed), cowork.chat.provider.<person id> (the picked provider's id) |
— |
| The sign-in's browser storage | localStorage: cowork.sign-in (oidc once the person started the identity provider's sign-in with its button; removed by a local sign-in and a sign-out); sessionStorage: cowork.sign-in.attempt (the tab's own sign-in after a session ended, until the tab has a session again) |
— |
| The sidebar's browser storage | localStorage: cowork.nav.collapsed (a JSON list of the slugs of the teams whose group the person collapsed; the current team's group stays open whatever it says) |
["globex"] |
| The theme's browser storage | localStorage: cowork.theme (the person's colour scheme, system, light or dark; nothing, anything else or a refused storage is system) |
dark |
| The tabs' broadcast channel | BroadcastChannel cowork.session, nothing stored: a tab posts {"person": <person id>} once it knows whose session it has, and {"signedOut": true} on a sign-out; a tab that has shown a person loads again on a sign-out or on another person's id; none where the browser has no BroadcastChannel |
{"signedOut": true} |
Agent header of cowork-mcp |
<client>/<model>/<session>: the MCP client's name (claude-code in the hooks), the model of the hook's input — in serve the one the last SessionStart or PostModelSwitch hook in the same CLAUDE_PROJECT_DIR recorded — or unknown, the hook's session id or eight hex characters per process; cowork-mcp/unknown/token-check and cowork-mcp/unknown/lookup for the two subcommands |
claude-code/claude-opus-5/1f0c9a2b |
| Repository identity | <host>[:<port>]/<path> of a git remote: scheme, user, a default port, .git and trailing slashes removed, the host lower-cased, the path's case kept; unique per team with the sub-directory |
github.com/acme/app for git@github.com:acme/app.git |
| Repository sub-directory | relative to the repository root, /-separated, no ..; empty for the whole repository |
services/billing |
| Proposed project key | the initials of the repository name's parts (-, _, .), else its first three letters, upper-cased; 2, 3, … appended while the key is taken |
VO for valkey-operator |
| MCP tool | session_start, get_ticket, … (the tools); in Claude Code mcp__plugin_cowork_cowork__<tool> through the plugin, mcp__cowork__<tool> for a server added by hand as cowork |
mcp__plugin_cowork_cowork__get_ticket |
| Commit strings for a ticket | the subject ends with (<PROJECT>-<number>), the body ends with the trailer Cowork-Ticket: <key>, the branch is <type>/<PROJECT>-<number>-<slug> |
feat/COW-42-export-attachments |
| Request id | X-Request-Id, a UUIDv7 the backend makes (an inbound one is ignored); the same value is request_id in a problem body and in the request log |
— |
| Problem type | https://cowork.dev/problems/<code, hyphenated> |
https://cowork.dev/problems/not-found |
| Attachment object | <team-id>/<attachment-id> in the configured bucket, derived, never stored |
— |
| Event channel | the PostgreSQL NOTIFY channel cowork_events |
— |
| Metric | cowork_<subsystem>_<name>_<unit>, a counter's ending in _total, a duration's in _seconds (Metrics) |
cowork_http_requests_total |
| Alert | Cowork<what>, in the chart's PrometheusRule, its section in docs/operations/metrics.md |
CoworkSchemaDirty |
| Event names | ticket.changed (uploads included; the kind derived for a parent whose derived stages a child of any team moved, with an id made for the event), comment.changed, question.changed, link.changed, interest.changed, membership.changed; on a person-level stream (?me=true) also inbox.changed; the control events resync and unavailable |
— |
| Thing | Name | Notes |
|---|---|---|
| PostgreSQL container | cowork-postgres, postgres:18 on localhost:5432 |
make postgres-up; POSTGRES_CONTAINER= and POSTGRES_PORT= move it |
| Development database | cowork, owned by cowork_owner, served as cowork_app |
created by make postgres-up; each password is the role's name |
| PostgreSQL that serves TLS | cowork-postgres-tls, postgres:18 on localhost:5433, a private authority and a server certificate for localhost and 127.0.0.1 made in the container at its first start; the authority's certificate copied to bin/cowork-postgres-tls-ca.crt |
make postgres-tls-up, for the integration test of COWORK_DATABASE_CA; superuser postgres / postgres, every key a development value; POSTGRES_TLS_CONTAINER= and POSTGRES_TLS_PORT= move it, make postgres-tls-down removes it with the copy |
| S3 container | cowork-minio, PGSTY Silo — docker.io/pgsty/silo, the maintained MinIO fork — pinned by release tag and digest (MINIO_IMAGE), on localhost:9000; it holds the client mcli |
make minio-up; root keys cowork / cowork-secret, development values; MINIO_CONTAINER= and MINIO_PORT= move it. The names stay MinIO's because Silo keeps MinIO's interface; a cowork-minio made before keeps its MinIO image until make minio-down minio-up |
| Dex container | cowork-dex, ghcr.io/dexidp/dex pinned by tag and digest (DEX_IMAGE), on localhost:5556; the issuer http://localhost:5556/dex, the client cowork with the secret cowork-dev-dex-secret |
make dex-up, configured from hack/dex/config.yaml, copied in; DEX_CONTAINER= and DEX_PORT= move it; keeps nothing, so make dex-down loses nothing |
| Dex users | ada@example.com (cowork-admins, cowork-users), bob@example.com (cowork-users, team-red), cyd@example.com (cowork-users), dan@example.com (team-red), each with the password dev-only-dex |
every credential of Dex is development-only and public in this repository |
| Container binding | CONTAINER_BIND=127.0.0.1 # default |
make postgres-up, postgres-tls-up, minio-up and dex-up publish their ports on the loopback address only; a container made before keeps its binding until it is removed |
| All three at once | make dev-up |
PostgreSQL, Silo and Dex, what make dev needs; the integration tier needs make postgres-tls-up besides |
| Integration run | roles cowork_it_owner and cowork_it_app, database cowork_it_<unix-nanoseconds>, bucket cowork-it-<unix-nanoseconds>; on the PostgreSQL that serves TLS the same roles and a database cowork_it_tls_<unix-nanoseconds> |
one database and one bucket per run, and one database per run of the authority's test; at the end the databases are dropped and the bucket emptied and removed |
| End-to-end stack | the network cowork-e2e and the containers cowork-e2e-postgres, -minio, -dex, -backend, -frontend, -ingress (E2E_NAME= renames them); the UI on https://localhost:18443 (E2E_PORT), Dex on http://localhost:5557/dex (E2E_DEX_PORT); the database cowork_e2e, the bucket cowork-e2e |
make e2e makes and removes it, make e2e-up and make e2e-down keep it between runs; the local administrator e2e-admin with the password e2e-only-cowork, development values |
| End-to-end data | the teams e2e (a project per test, key E and seven random characters; the mapping team-red → member), e2e-other (the administrator's second team, a project per test that needs one there), e2e-visual (the project VIEW of the screenshots) and e2e-d<random> (one per run of the dashboard's path, its projects OPEN and HIDDEN), the token e2e-seed, local accounts e2e-<random> |
made by the suite through the API (testing.md) |
| Development seed | person dev, team dev, an admin membership, a token named dev-seed |
make dev-seed, which runs backend/test/devseed — its flags -team and -username name others —; every run prints a new token once |
| Development stack | make dev: the backend on localhost:8080, the UI on https://localhost:4200 (self-signed), the local administrator dev with the password dev-only-cowork, Dex as the identity provider (allowed cowork-users, administrator group cowork-admins, the button Sign in with Dex), the group mapping team-red → member in the team dev, the bucket cowork-dev, a second person sam, demo projects COW, OPS, WEB |
two ways in: the form as dev, or Sign in with Dex as one of the four Dex users; state in .dev/ (untracked): token (the demo data's), session-key, backend.log, the built cowork, and the PrimeUI key in primeui-license; make dev-reset empties the database |
| Thing | Pattern | Example |
|---|---|---|
| Migration | backend/internal/store/migrations/NNNNNN_<snake_name>.up.sql, versions 1..n without a gap, no down files |
000001_tenants.up.sql |
| Container images | guidedtraffic/cowork-backend:<semver>, guidedtraffic/cowork-frontend:<semver> |
both carry the release version |
| Helm chart | cowork in the repository https://guided-traffic.github.io/cowork/, source deploy/helm/cowork/; chart version = release version |
helm install cowork cowork/cowork --version 0.1.0 |
| Go module | github.com/guided-traffic/cowork/backend |
— |
| Angular project | frontend, output frontend/dist/frontend/browser/ |
— |
| Ticket (interim, in this repository) | docs/tickets/NNN-<kebab-slug>.md, id: T<n> |
rules in docs/tickets/README.md |
| API document | backend/api/openapi.yaml and one file per path family, bundled by make generate into backend/api/openapi.gen.json |
served at /api/v1/openapi.json |
cowork-mcp release binary |
cowork-mcp-<version>-<os>-<arch>, .exe on windows, beside it <file>.sha256; os linux, darwin, windows, arch amd64, arm64; attached to the GitHub release, each with a build provenance attestation (gh attestation verify <file> --repo guided-traffic/cowork --signer-workflow guided-traffic/cowork/.github/workflows/build.yml --source-ref refs/tags/v<version>) |
cowork-mcp-0.3.0-darwin-arm64 |
| Export archive | <team>-<PROJECT>-<YYYYMMDD>.tar.gz for a project, <team>-<YYYYMMDD>.tar.gz for a team, the day in UTC: manifest.json, links.json and attachments.json at its root, each ticket at <team>/<PROJECT>-<number>.md |
acme-COW-20261006.tar.gz |
| Ticket files an import reads | NNN-<slug>.md and local_NNN-<slug>.md of a repository — id: T<n> naming the same number —, <PROJECT>-<number>.md of an export; anything else is skipped |
117-the-export-is-slow.md # example |
| Binding file | .cowork.yaml, the nearest at or above the working directory within the repository; schema at /api/v1/schemas/cowork-yaml.json; the team under team — tenant, its key before, is still read for this release, and both together must name the same slug. A cowork-mcp of 0.14 ignores a file that names team at all, so a repository keeps tenant: alone — the file create_project offers in this release — until every machine that works in it runs a cowork-mcp of this release (upgrade) |
tenant: acme, project: APP in this release; team: acme once every machine has moved |
Session memory of cowork-mcp |
<user cache directory>/cowork-mcp/<host and path of COWORK_URL>_<team>_<PROJECT>.json, other characters than letters, digits, . and - as _ |
~/.cache/cowork-mcp/cowork.example.com_acme_APP.json |
Model of the session, for the mark of cowork-mcp serve |
<user cache directory>/cowork-mcp/model-<the first 16 hex characters of the SHA-256 of CLAUDE_PROJECT_DIR>.json, holding project_dir and model; written by the SessionStart and PostModelSwitch hooks |
~/.cache/cowork-mcp/model-5289543d4663e377.json for /home/ada/src/app |
| Claude Code plugin | the marketplace cowork in .claude-plugin/marketplace.json, the plugin cowork in claude/cowork/; its options cowork_url and cowork_token |
claude plugin install cowork@cowork |
The chart's Ingress routes /api/ and /auth/ to the backend Service and every other path to the
frontend Service (ADR 0001 D3).
| Path | Backend | Frontend (nginx) |
|---|---|---|
/healthz |
liveness, through the backend Service | the Ingress sends it here: nginx's own health, {"status":"ok"} |
/readyz |
readiness: a database ping, through the backend Service | the Ingress sends it here, and nginx answers it with the UI shell (index.html, 200), which says nothing about the backend |
/api/v1/… |
the JSON API, routed here by the Ingress; errors are RFC 9457 application/problem+json with a stable code |
not routed here; a request that arrives all the same gets 404 with a problem naming the cause |
/auth/options, /auth/local, /auth/oidc/login, /auth/callback, /auth/logout |
the browser's login flows, routed here like /api/, in the API document; /auth/callback is the redirect URI to register at the identity provider |
the same 404 problem |
/api/v1/teams/<slug>/events |
the event stream, answered with X-Accel-Buffering: no; ?me=true makes it the person-level stream |
— |
/api/v1/teams/<slug>/chat |
a turn of the chat, a POST answered as a stream with X-Accel-Buffering: no |
— |
/api/v1/tenants, /api/v1/tenants/<slug>/… |
the names before of /api/v1/teams and /api/v1/teams/<slug>/…, deprecated and served for this release, each answered exactly as its team path (API) |
— |
| hashed bundles | — | served with Cache-Control: public, max-age=31536000, immutable |
/metrics |
on the metrics listener only, COWORK_METRICS_ADDR (:8081): the Prometheus text format, no authentication, on no Service; the API's port answers it 404 |
not served; stub_status listens on 127.0.0.1:8082, inside the pod only |
| everything else | 404 problem details |
index.html with Cache-Control: no-store |
| Media type | Where |
|---|---|
application/json |
request bodies and responses |
application/problem+json; charset=utf-8 |
every error |
multipart/form-data |
an upload: the part file, optionally the part comment_id; an import: one or more parts file |
text/csv |
on Accept: text/csv: the audit record, the team's time entries, the time report |
text/markdown; charset=utf-8 |
a ticket's canonical Markdown and its context |
text/event-stream |
the event stream, a turn of the chat |
text/plain |
a scrape of /metrics on the metrics listener, the Prometheus text format |
application/gzip |
the project's and the team's export, with Content-Disposition: attachment |
| Document | What it is for |
|---|---|
| docs/developer/ | Contributor entry point and how the code works: layout, package map, architecture, build/test/lint matrix, testing, CI and release, checklists, conventions |
| docs/developer/development-credentials.md | Every development-only username, password, key and token of make dev, its containers and the test tiers, with the file that sets it |
| docs/operations/ | Installing and running: the database roles, the Secrets and the object storage, the two migration modes, the Ingress and its controller's settings; upgrading; backups, the consistency check and a restore; runtime behaviour, the limits, what answers what, the event stream behind an Ingress; the metrics, their alerts and what to do when one fires; Claude Code against an installation; importing tickets and the export as a backup |
| deploy/examples/ | A CloudNativePG cluster, the values of PGSTY Silo's Helm chart and the mcli commands of a bucket-scoped key to copy and adapt — examples checked for syntax against the CRD schemas and the chart of the releases they name, not supported deployments |
| docs/security/ | The security architecture, one page per perspective; SECURITY.md to report a vulnerability |
| docs/adr/ | Why cowork is the way it is |
| OpenID Connect Core 1.0, Dex | The standard the login through an identity provider follows, and the issuer it is developed and tested against |
| docs/tickets/ | The interim work lists and their rules — the plan among them, a family ticket for each open phase, since the project plan was consumed into them |
| CLAUDE.md | The working rules for an LLM session in this repository |
Go 1.27, Node.js 26 with npm, Docker (for the local PostgreSQL and Silo and the images),
Helm 3 or 4, openssl. make help lists every target.
The quickest way to see cowork, with demo data and the UI reloading as you edit:
make dev # PostgreSQL, Silo, Dex, the backend, demo data and the UI on https://localhost:4200 — Ctrl-C stops itSign in as dev with the development-only password dev-only-cowork, or with Sign in with Dex
as ada@example.com (a global administrator), bob@example.com (a member of the team dev by
his group), cyd@example.com (behind the gate, in no team until granted) or dan@example.com
(refused at the gate), each with the development-only password dev-only-dex. The dev server uses a
self-signed certificate — HTTPS, because Safari stores no Secure session cookie from
http://localhost — so the browser asks about it once. The parts by hand:
make postgres-up # postgres:18 on :5432 — database cowork, roles cowork_owner (migrates) and cowork_app (serves); make dev-up adds Silo and Dex
make dev-seed # migrates; then a person, the team "dev", an admin membership and a token, printed once
make run # the backend on :8080: migrates as cowork_owner, serves as cowork_app (text logs)
make frontend-serve # the Angular dev server on :4200, /api and /auth proxied to :8080 — the stand-in for the Ingressmake run needs no server key: it makes a throw-away one per start, so list cursors from an
earlier run answer invalid_cursor. make dev-seed makes a token: an agent's token with scope
write and every capability, so its creating POSTs carry an Idempotency-Key.
TOKEN=cwk_… # the token make dev-seed printed
AUTH="Authorization: Bearer $TOKEN"
curl -s localhost:8080/readyz # {"status":"ready"}
curl -s -H "$AUTH" localhost:8080/api/v1/me # the person and the team dev
curl -s -H "$AUTH" -H "Idempotency-Key: $(uuidgen)" -H 'Content-Type: application/json' \
-d '{"key":"COW","name":"cowork"}' localhost:8080/api/v1/teams/dev/projects
curl -s -H "$AUTH" -H "Idempotency-Key: $(uuidgen)" -H 'Content-Type: application/json' \
-d '{"type":"task","title":"Try cowork","severity":"low","security":"none","effort":"XS"}' \
localhost:8080/api/v1/teams/dev/projects/COW/tickets # the ticket dev/COW-1
curl -s -H "$AUTH" localhost:8080/api/v1/tickets/dev/COW-1 # by its key
curl -s -H "$AUTH" localhost:8080/api/v1/teams/dev/projects/COW/tickets/1/markdown
open https://localhost:4200 # the UI, after make dev; sign in as devTo try the login — a person in a browser session — start the backend with a local
administrator and the origin the browser sees (make dev uses https://localhost:4200).
Both variables are required together, the password is at least twelve characters, and the first
thing the administrator does is create the first team:
COWORK_BASE_URL=http://localhost:4200 COWORK_LOCAL_ADMIN_USERNAME=admin \
COWORK_LOCAL_ADMIN_PASSWORD='a long development password' make run
curl -si -H 'Origin: http://localhost:4200' -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"a long development password"}' localhost:8080/auth/local
# 200 {"password_change_required":false} and Set-Cookie: __Host-cowork-session=…; HttpOnly; Secure; SameSite=Lax; Path=/
COOKIE='__Host-cowork-session=…' # the value of that cookie
curl -s -H "Cookie: $COOKIE" localhost:8080/api/v1/me # the person; global_admin is true
curl -s -H "Cookie: $COOKIE" -H 'Origin: http://localhost:4200' -H 'X-Requested-With: cowork' \
-H 'Content-Type: application/json' -d '{"slug":"acme","name":"Acme"}' localhost:8080/api/v1/teamsEvery write of a session carries the Origin of COWORK_BASE_URL and X-Requested-With: cowork
(CSRF); a token's request carries neither.
The login through Dex by hand is what make dev does: make dex-up, then make run with
COWORK_OIDC_ISSUER=http://localhost:5556/dex, COWORK_OIDC_CLIENT_ID=cowork,
COWORK_OIDC_CLIENT_SECRET=cowork-dev-dex-secret, COWORK_OIDC_ALLOWED_GROUPS=cowork-users and
COWORK_BASE_URL=https://localhost:4200 — the redirect URI Dex knows for development — and the UI
from make frontend-serve NG_SERVE_FLAGS=--ssl (hack/dev.sh,
hack/dex/config.yaml).
Without object storage the backend refuses uploads (501 uploads_disabled). To try
attachments, start Silo, create a bucket with any S3 client — no target creates one; the
container's own mcli will do — and hand the backend its keys:
make minio-up # Silo on :9000, root keys cowork / cowork-secret
docker exec cowork-minio mcli alias set local http://localhost:9000 cowork cowork-secret
docker exec cowork-minio mcli mb local/cowork
COWORK_S3_ENDPOINT=http://localhost:9000 COWORK_S3_BUCKET=cowork \
COWORK_S3_ACCESS_KEY_ID=cowork COWORK_S3_SECRET_ACCESS_KEY=cowork-secret make runmake test # backend unit + frontend unit
make dev-up postgres-tls-up # the integration tier needs PostgreSQL, one that serves TLS, Silo and Dex
make test-integration # a database and a bucket of its own per run
make e2e-browsers # once: Playwright's Chromium and WebKit
make docker-build e2e # the end-to-end tier: both images behind the Ingress stand-in, a stack of its own
make lint frontend-lint helm-lintmake docker-build # guidedtraffic/cowork-backend:latest and guidedtraffic/cowork-frontend:latestThe database comes first: one PostgreSQL 18 database, an owner role that owns it and a
runtime role that owns nothing — both created by you, as
installation.md shows; a Secret
without a URL key, as an operator writes one, is read by its component keys instead
(the Secrets), and
deploy/examples/ has a CloudNativePG cluster and a Silo with its bucket to
copy — checked for syntax only.
kubectl create namespace cowork
kubectl -n cowork create secret generic cowork-database \
--from-literal=databaseUrl='postgres://cowork_app:CHANGE-ME@postgres:5432/cowork?sslmode=require'
kubectl -n cowork create secret generic cowork-database-owner \
--from-literal=databaseUrl='postgres://cowork_owner:CHANGE-ME@postgres:5432/cowork?sslmode=require'
kubectl -n cowork create secret generic cowork-session \
--from-literal=sessionKey="$(openssl rand -base64 32)"
helm repo add cowork https://guided-traffic.github.io/cowork/
# 0.4.0, the class and the host are examples; 0.4.0 is the first release whose Ingress routes the API
helm upgrade --install cowork cowork/cowork --version 0.4.0 -n cowork \
--set database.existingSecret=cowork-database \
--set database.owner.existingSecret=cowork-database-owner \
--set session.existingSecret=cowork-session \
--set ingress.enabled=true --set ingress.className=my-ingress-class \
--set 'ingress.hosts[0].host=cowork.example.com'The Ingress sends /api/ and /auth/ to the backend and everything else to the frontend; the chart
and the frontend image of a release move together. Its controller must pass a body above the
backend's limit and wait past its request timeout — the chart's notes print both figures —, pass
text/event-stream unbuffered, and write the address it saw into X-Forwarded-For; the chart sets
none of that (expose it). ingress-nginx is retired
(March 2026, no security fixes since): do not install it for a new installation. There is no UI
without a route like this: a port-forward to the frontend alone serves the shell without its API.
The login needs a local administrator and the public URL: a Secret with its username and
password, and the origin the browser shows. The administrator logs in, creates the first team
(or bootstrap.team.slug and .name create it at start) and the accounts of its people:
kubectl -n cowork create secret generic cowork-local-admin \
--from-literal=username=admin --from-literal=password="$(openssl rand -base64 24)"
helm upgrade cowork cowork/cowork -n cowork --reuse-values \
--set localAdmin.existingSecret=cowork-local-admin \
--set backend.config.baseURL=https://cowork.example.com \
--set bootstrap.team.slug=acme --set bootstrap.team.name="Acme Corp"With an identity provider — instead of the local administrator, or beside it — register a
confidential client there with the redirect URI https://cowork.example.com/auth/callback, put its
secret into a Secret, and name the issuer, the client and the gate:
kubectl -n cowork create secret generic cowork-oidc --from-literal=clientSecret='CHANGE-ME'
helm upgrade cowork cowork/cowork -n cowork --reuse-values \
--set backend.config.baseURL=https://cowork.example.com \
--set auth.oidc.issuer=https://login.example.com/realms/acme \
--set auth.oidc.clientId=cowork --set auth.oidc.existingSecret=cowork-oidc \
--set 'auth.oidc.allowedGroups={cowork-users}' --set auth.oidc.adminGroup=cowork-adminsEvery value above is an example. The members of the administrator group are global
administrators; with bootstrap.team.slug and .name the start maps the group to the first
team's admin role, and the installation needs no local administrator. The backend refuses to
start while it cannot fetch the provider's discovery document
(installation.md).
Behind an Ingress the login throttle has to be told which networks are the proxies, or it counts
one address for every browser behind a controller pod:
--set backend.config.trustedProxies=<the Ingress controller's network>. Every other pod inside
that network that reaches the backend can then choose its address, which only a network policy of
the cluster's prevents — the chart ships none
(the client address).
The backend pods answer Prometheus at /metrics on port 8081, without authentication and on no
Service; a network policy that admits only the monitoring namespace to the port is the cluster's, and
--set metrics.enabled=false closes it. With kube-prometheus,
--set metrics.podMonitor.enabled=true --set metrics.prometheusRule.enabled=true and the label your
Prometheus selects them by wire the scraping and the alerts
(metrics).
Attachments need an S3-compatible bucket and three more values; without them uploads are
refused. Without a local administrator or an identity provider nobody can log in. A release publishes the chart and both images (guidedtraffic/cowork-backend,
guidedtraffic/cowork-frontend on Docker Hub) with one version; the chart's image tags follow
its appVersion. Details — the roles, the Secrets,
the object storage, the Ingress annotations, the CloudNativePG note:
docs/operations/installation.md.
Upgrade and uninstall
helm repo update cowork
helm upgrade cowork cowork/cowork --version <new> -n cowork --reset-then-reuse-values # Helm 3.14+ or 4
helm uninstall cowork -n cowork # the database and the bucket are left untouched--reset-then-reuse-values keeps your values and takes the new chart's defaults; --reuse-values
keeps the old chart's defaults too, so a value a release adds is missing
(upgrade). The pending migrations run before the new
server starts — in the init container of each new backend pod, or in the migration Job in job mode;
see docs/operations/runtime.md.
Take a backup first; the procedure — the order, where the schema stands, a rollback, a dirty
schema — is docs/operations/upgrade.md.
cowork-mcp of the installation's release on the PATH, a token from the UI's token page —
an agent token with write scope — and the plugin of this repository:
cowork-mcp version # from the release assets, or make build-mcp
COWORK_URL=https://cowork.example.com COWORK_TOKEN=cwk_… cowork-mcp token check # example values
claude plugin marketplace add guided-traffic/cowork
claude plugin install cowork@cowork # then /plugin configure cowork@cowork: the URL and the token
cd ~/src/app && claude # the session starts with the repository's project, or a proposalThe setup by hand, the token choices, the CLAUDE.md block and .cowork.yaml:
docs/operations/claude-code.md.
Every variable is read once at backend start. Invalid values and missing required ones end the
process with exit code 1: every problem the configuration has is listed together, and what only
serve or migrate needs is checked once the rest passes. An error names the variable and
quotes a rejected setting such as a size or a duration, never the value of a URL, a key or a
secret. Source: backend/internal/config/config.go. A
size is a number of bytes or a number with KiB, MiB or GiB; a duration is a Go duration
(30s, 5m). Where a limit takes 0, 0 switches it off —
runtime.md, limits says what that costs.
Server and database
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_DATABASE_URL |
— (required, or its components) | postgres://cowork_app:…@postgres:5432/cowork?sslmode=require # example |
The runtime role's connection URL; every request runs as this role. Set it or the components below, not both. It must not be a superuser, have BYPASSRLS, own a relation of the schema or be a member of the owner role — serve and migrate refuse it otherwise. pool_max_conns=<n> in the URL sizes the connection pool. Security: a credential: from a Secret. cowork never logs it; a URL pgx cannot parse appears in the startup error with its password masked, which pgx does on a best-effort basis |
COWORK_DATABASE_OWNER_URL |
empty # default |
postgres://cowork_owner:…@postgres:5432/cowork?sslmode=require # example |
The owner role's URL, which the migrations run under; it must name another role than COWORK_DATABASE_URL. Set it or its components below, not both. Required by cowork migrate, and by cowork serve while COWORK_MIGRATE_ON_START is true. Security: the owner can switch row-level security off, so a serving process that holds this URL loses the second line of team isolation against its own compromise. The chart hands it to the migration run only: the migrate init container, or the migration Job in job mode |
COWORK_DATABASE_HOST, COWORK_DATABASE_PORT, COWORK_DATABASE_NAME, COWORK_DATABASE_USER, COWORK_DATABASE_PASSWORD, COWORK_DATABASE_SSLMODE |
empty # default |
postgres.cowork.svc, 5432, cowork, cowork_app, CHANGE-ME, require # example |
The runtime role's connection as components, for a Secret that holds no URL (ADR 0058 D4): the backend composes postgres://user:password@host:port/name?sslmode=…, the user, the password and the name escaped. The host, the name, the user and the password are required together; without the port it is 5432, without the sslmode the driver's default, prefer. One of them beside COWORK_DATABASE_URL, a port that is no port, an sslmode other than disable, allow, prefer, require, verify-ca, verify-full, or a host with /, ?, #, @ or a space refuses the start. Security: the password is a credential, read as it is and never echoed — an error quotes the port, the sslmode or the host, never the password |
COWORK_DATABASE_OWNER_HOST, _PORT, _NAME, _USER, _PASSWORD, _SSLMODE |
empty # default |
as above # example |
The owner role's connection as components, by the same rules, in place of COWORK_DATABASE_OWNER_URL. Security: as COWORK_DATABASE_OWNER_URL |
COWORK_DATABASE_CA |
empty # default |
/etc/cowork/database-ca/ca.crt # example |
A PEM file of the private authority the database server's certificate chains to, such as CloudNativePG's own (ADR 0058 D3). The backend names it as the sslrootcert of both roles' connections, a URL and one composed of components alike, so the runtime pool, the owner's and the migration run trust that authority alone — the system pool is not consulted —; the driver reads it as libpq does: verify-full checks the chain and the host, verify-ca and require the chain. Beside an sslmode that checks nothing — disable, allow, prefer, or none, the driver's prefer —, beside a URL that names an sslrootcert of its own, or beside a connection that is no postgres:// URL, the start is refused, the error naming the variables and never the URL. Empty leaves the connections as they are given. Security: with verify-full, a peer that redirects the connection cannot present a certificate of its own; whoever may replace the file is trusted like whoever may read the roles' Secrets |
COWORK_MIGRATE_BOOTSTRAP |
false # default |
true, false |
Read by cowork migrate: with true, after the schema step it synchronises the local administrator and the bootstrap team as cowork serve does at its start — as the runtime role, under the bootstrap's advisory lock, with the same variables (ADR 0057 D4). The chart's migration Job sets it; the init container does not, because a bootstrap given no administrator would deactivate the one the serving container keeps |
COWORK_MIGRATE_ON_START |
true # default |
true, false |
serve applies pending migrations before it listens; with false it refuses to start while migrations are pending. The chart sets false and migrates in an init container or, in job mode, in a Job |
COWORK_SESSION_KEY |
— (required by serve) |
standard base64 of at least 32 bytes, openssl rand -base64 32 |
The server key: keys derived from it sign the list cursors, key the fingerprint of an idempotent request and the hashes of a client's address — the login throttle's and the one every audit row of a request carries — and seal the identity provider's login state and refresh tokens. Every replica needs the same key. A new key invalidates the cursors clients hold (400 invalid_cursor), fails the logins through the provider under way, gives every address another hash — the login throttle counts it anew, and audit rows before and after cannot be compared —, refuses the retry of an idempotent request keyed before it (422 idempotency_mismatch), and ends each session of the provider that holds a refresh token at its next refresh — no previous key is kept to open what the old one sealed (ADR 0031 D1; rotating it); it signs no session, and a session of the local login survives it. Security: a secret: from a Secret, never echoed; the chart has no inline value for it. With the database, it opens the stored refresh tokens (H-27) and reverses the audit rows' IPv4 hashes (H-30). make run makes a throw-away one |
COWORK_LISTEN_ADDR |
:8080 # default |
host:port |
The backend listener for API and health |
COWORK_METRICS_ADDR |
:8081 # default |
host:port, another than COWORK_LISTEN_ADDR; empty switches it off |
The metrics listener: Prometheus text at /metrics and nothing else (Metrics, docs/operations/metrics.md). Bound beside the API's listener before either serves — a taken port refuses the start — and shut down with it. Unlike every other variable, one set to an empty value is not unset: it closes the port, and nothing is recorded. An address without a port, or the API's, refuses the start, naming the variable. Security: no authentication — every pod that reaches the backend pods reads the installation's activity in counts, never a person, a ticket or a token, and a team only by its id on the consistency check's counts (H-63); admit only the monitoring namespace to the port, or switch it off |
COWORK_LOG_LEVEL |
info # default |
debug, info, warn, error |
Minimum level |
COWORK_LOG_FORMAT |
json # default |
json, text |
text for a terminal |
COWORK_SHUTDOWN_TIMEOUT |
15s # default |
a positive duration | Drain bound after SIGTERM; the event streams end as the drain begins. A drain that outlasts it ends the process with exit 1. Keep it below the pod's grace period |
COWORK_BASE_URL |
empty # default |
https://cowork.example.com # example |
The public URL, as the browser shows it: an origin — http or https, a host, an optional port, no path, no query. Required while COWORK_LOCAL_ADMIN_USERNAME or COWORK_OIDC_ISSUER is set. The CSRF check compares the Origin (or Referer) of every write of a session, and of the local login, with it exactly (CSRF); a URL the browser does not show makes every such write 403 csrf, and without one no write of a cookie passes. The identity provider's redirect URI is this URL and /auth/callback, and its logout returns to /login. Security: nothing secret; a wrong value locks the browser out, never lets another origin in |
Login, sessions and accounts (ADR 0031, 0032, 0033, 0035 D4, 0037). Chart values are in Helm chart values; the pages are sessions, local accounts and CSRF.
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_LOCAL_ADMIN_USERNAME |
empty # default |
admin # example |
With COWORK_LOCAL_ADMIN_PASSWORD: the one account the configuration keeps, a global administrator — it creates teams and holds no role in any until it grants itself one — and a full account (local:<username>). Synchronised at every start, after the migrations, under an advisory lock: created; re-hashed with all its sessions ended and all its tokens revoked when the password changed; deactivated, its tokens revoked and its sessions ended, when both variables are empty; never deleted. Both set or both empty — one alone refuses the start, naming the missing variable. Needs COWORK_BASE_URL. 1–63 characters of a-z, 0-9, ., _, -, starting with a letter or a digit |
COWORK_LOCAL_ADMIN_PASSWORD |
empty # default |
at least COWORK_PASSWORD_MIN_LENGTH characters |
The account's password, read as it is — spaces included. Security: a secret: from a Secret, never echoed or logged; anyone who can read the pod spec or the Secret can read it. A leaked one stays valid until the Secret is rotated and the backend restarted; that rotation also unlocks a locked administrator, ends its sessions and revokes its tokens — review the grants it made afterwards (recovery). The account's password cannot be changed in the UI: this is its source |
COWORK_BOOTSTRAP_TEAM_SLUG |
empty # default |
acme # example |
With COWORK_BOOTSTRAP_TEAM_NAME: while no team exists, a start creates this team, gives the local administrator, when one is configured, a marked grant as its admin, and maps COWORK_ADMIN_GROUP, when one is set, to its admin role; once a team exists these variables do nothing, whatever they say. Both or neither, and only with the local administrator or an administrator group, which becomes the team's first administrator. Its name before, COWORK_BOOTSTRAP_TENANT_SLUG, is still read (deprecated variable names) |
COWORK_BOOTSTRAP_TEAM_NAME |
empty # default |
Acme Corp # example |
The team's name, 1–200 characters. Its name before, COWORK_BOOTSTRAP_TENANT_NAME, is still read |
COWORK_PASSWORD_MIN_LENGTH |
12 # default |
8 to 1024 |
The shortest password of a local account, counted in characters; the policy is length only — no character classes, no history — and it holds for the local administrator too. Below 8 the start is refused |
COWORK_LOGIN_LOCKOUT |
window # default |
window, admin |
window: a username locked by failures is free again when the 15-minute window passes. admin: it stays locked until a team administrator unlocks it (DELETE …/accounts/{username}/lockout) — or, for the local administrator, until the Secret is rotated and the backend restarted. Security: admin lets anyone who knows a username keep its account locked |
COWORK_LOGIN_MAX_FAILURES |
5 # default |
a count; 0 never locks |
Failed attempts of one username within fifteen minutes that lock it — a username nobody has too, so neither the answer nor the lock says whether an account exists |
COWORK_LOGIN_ADDRESS_LIMIT |
20 # default |
a count; 0 disables |
Login attempts — and current passwords of a change, PUT /api/v1/me/password — of one client address, an IPv6 client by its /64, within a minute before 429 too_many_attempts, counted before any hash is computed and held for parallel requests as for sequential ones. The client address is the TCP peer's unless the peer is inside COWORK_TRUSTED_PROXIES (H-17); with that list empty, behind an Ingress the peer is a controller pod and the limit holds for every browser behind it |
COWORK_TRUSTED_PROXIES |
empty # default |
comma-separated CIDRs, IPv4 and IPv6; 10.244.0.0/16,fd00:10:244::/48 # example |
The networks of the proxies in front of the backend. The client address — which the login throttle counts and whose keyed hash every audit row of a request carries — is found by walking X-Forwarded-For from the right: from the TCP peer, while the current address is inside these networks the entry to its left becomes the current one; the first address outside them is the client, and nothing to its left is read. Empty: the peer is the client and the header is never read. A single host is /32 or /128; an entry that is no CIDR refuses the start, naming the variable and that entry. The proxy in front of the backend is the Ingress controller. Security: name the proxies and no more — a client inside a trusted network chooses its own address, which defeats the throttle and lets it fill another client's bucket; a network that holds other pods lets each of them that reaches the backend do the same, which only a network policy of the cluster's prevents — the chart ships none; an empty list leaves one address for every browser behind a controller pod (installation.md) |
COWORK_SESSION_LIFETIME |
12h # default |
a positive duration | The absolute lifetime of a session; it is also the cookie's Max-Age |
COWORK_SESSION_IDLE |
2h # default |
a positive duration | How long a session may lie unused; a request within it extends the session up to the lifetime — every read, the event stream's connections and reconnects included, but no write the CSRF check refuses. The idle clock moves at most once a minute. An open tab whose stream reconnects or polls keeps its session up to the lifetime with nobody at it (H-109) |
COWORK_TOKEN_DEFAULT_LIFETIME |
2160h (90 days) # default |
a positive duration, not above the maximum | The lifetime of a token whose creator named none |
COWORK_TOKEN_MAX_LIFETIME |
8760h (one year) # default |
a positive duration | The longest lifetime a token may have; a longer request is shortened to it and the answer says what the token got |
Identity provider (ADR 0029,
0030,
0031 D1, D3,
0035 D8). COWORK_OIDC_ISSUER turns the login through
an OpenID Connect provider on; every other variable here is read only with it, and set without it
refuses the start, naming itself. Chart values are auth.oidc.* in
Helm chart values; the page is
identity provider.
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_OIDC_ISSUER |
empty # default |
https://login.example.com/realms/acme # example |
The issuer. https://, or http:// on a loopback host (localhost, 127.0.0.0/8, ::1) for development; no user, query or fragment. Kept as written: it must equal the issuer the discovery document names, trailing slash and all. The backend fetches <issuer>/.well-known/openid-configuration at every start — no redirect followed, at most 1 MiB — and refuses to start when it cannot, when the document's authorization, token, keys or UserInfo endpoint is neither https nor http on a loopback host, or when it names no signature algorithm cowork verifies (the asymmetric ones; RS256 when it names none); an end_session_endpoint that breaks the rule is dropped with a warning. Requires COWORK_BASE_URL — the redirect URI to register at the provider is COWORK_BASE_URL + /auth/callback — and, for serve, the client's id and secret; cowork migrate reads the issuer and COWORK_ADMIN_GROUP for the bootstrap without them |
COWORK_OIDC_CLIENT_ID |
— (required by serve with the issuer) |
cowork # example |
cowork's client at the provider |
COWORK_OIDC_CLIENT_SECRET |
— (required by serve with the issuer) |
— | The client's secret, read as it is; a public client without a secret is not supported. A secret the provider no longer accepts (invalid_client) logs nobody out: sessions are served on their groups, and the log says so at error level. Security: a secret: from a Secret, never echoed; the chart reads it from auth.oidc.existingSecret only. With it, the server key and the database, the stored refresh tokens can be redeemed at the provider (H-27) |
COWORK_OIDC_SCOPES |
openid profile email groups offline_access # default |
separated by spaces or commas; openid among them |
The scopes a login asks for. offline_access brings the refresh token the groups refresh needs — most providers issue one only for it; without one a session never reads the groups anew and is judged on the person's stored groups — their last login's, or another session's last read — until it ends (H-25) |
COWORK_OIDC_GROUPS_CLAIM |
groups # default |
a claim's name; roles # example |
The claim that carries the groups, read from the ID token and, when it lacks it, from UserInfo; a string is one group, a list of strings the groups, anything else fails the login |
COWORK_OIDC_ALLOWED_GROUPS |
empty # default |
comma-separated group names, each at most 256 characters; cowork-users,Domain Users # example |
The gate: a person logs in through the provider only when one of their groups is here or is COWORK_ADMIN_GROUP, matched exactly, case and all; trimmed, a repetition dropped; a name cannot hold a comma. With this and COWORK_ADMIN_GROUP both empty the gate admits nobody: the login page offers no button and the start warns. A personal access token of a person of the provider meets the gate as well, on the groups of their last login or refresh (401 not_allowed). A person of another issuer than the configured one — or any person of a provider once none is configured — is outside the gate whatever their groups. Security: whoever can put a person into an allowed group at the provider lets them in |
COWORK_ADMIN_GROUP |
empty # default |
a group name of at most 256 characters; cowork-admins # example |
Its members are global administrators and pass the gate: they create teams — each of which they then administer — and hold no role in a team they were not given until they grant themselves one, which the team's audit records; while no team exists, they and the local administrator alone log in. With COWORK_BOOTSTRAP_TEAM_SLUG the start maps it to the bootstrap team's admin role, and no local administrator is needed. Security: whoever can change this group at the provider administers the installation |
COWORK_OIDC_GROUPS_REFRESH |
15m # default |
a duration of at least 1m |
How often a session of the provider reads the person's groups again — on its next request, and at an open event stream's heartbeat — and how often a token of a person of the provider meets the gate. Shorter: a group left at the provider reaches cowork sooner, for a call to the provider per session and interval |
COWORK_OIDC_GROUPS_MAX_AGE |
168h # default |
a duration longer than COWORK_OIDC_GROUPS_REFRESH, which the start refuses otherwise |
How old a person's groups — read at their last sign-in, or by the last session refresh that read them — may be for their personal access tokens to work: older ones refuse every token of the person with 401 not_allowed until they sign in to the browser once, and an open event stream of such a token ends at its heartbeat. A local account's tokens are not affected. Security: bounds how long a person removed from a group at the provider keeps working tokens without signing in to the browser (H-23); shorter asks people to sign in more often |
COWORK_OIDC_EMAIL_TRUSTED |
false # default |
true, false |
Whether an address about which the provider says nothing — no email_verified claim, as Entra sends none — counts as verified when an administrator grants a role by e-mail address (POST …/members). With false only an address the provider marked verified finds a person, and a person of a provider that sends no claim is admitted by a group mapping instead; an address marked unverified never matches. Security: set true only for a provider whose addresses administrators issue: where people choose their own address unverified, one can claim a colleague's and be granted the colleague's role (H-26) |
COWORK_OIDC_DISPLAY_NAME |
single sign-on # default |
at most 64 characters; Acme SSO # example |
The login page's button: "Sign in with <name>" |
Limits (ADR 0039)
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_MAX_JSON_BODY |
1MiB # default |
a size; 0 disables |
A larger JSON body is 413 payload_too_large. It holds every operation whose body the API document does not declare as an upload, whatever Content-Type the request names; a body of a type the operation does not take is 415 unsupported_media_type before it is read |
COWORK_REQUEST_TIMEOUT |
30s # default |
a duration, not negative; 0 disables |
A request still running is cancelled and answered 504 timeout; the event stream is exempt |
COWORK_MAX_PAGE_SIZE |
200 # default |
a count; 0 disables |
The largest page a list returns; a larger limit is clamped, not refused |
COWORK_MAX_QUERY_LENGTH |
256 # default |
a count of characters; 0 disables |
A longer full-text query q — the ticket lists' filter and the search — is 400 validation_failed |
COWORK_ATTACHMENT_MAX_BYTES |
10MiB # default |
a size; 0 disables |
The largest upload; above it 413, before anything is stored. Uploads are buffered in memory: with 0 one upload at a time is read whole, whatever its size (docs/security/attachments.md) |
COWORK_ATTACHMENT_MAX_PER_TICKET |
100 # default |
a count; 0 disables |
The attachments one ticket takes; one more is 409 attachment_limit |
COWORK_ATTACHMENT_TEAM_QUOTA |
0 # default |
a size such as 10GiB; 0 sets none |
The bytes one team's attachments hold together — every ticket's, confidential ones included, and a deleted ticket's until the purge; an upload that would go above it is 409 attachment_quota before anything is stored. Off by default: an installation of several teams sets it, or one team can fill the storage all of them share (docs/security/attachments.md). Its name before, COWORK_ATTACHMENT_TENANT_QUOTA, is still read (deprecated variable names) |
COWORK_MAX_IMPORT_BYTES |
50MiB # default |
a size; 0 disables |
An import's upload — a tar.gz, a zip or Markdown files — and what its files hold unpacked, the ones it skips by the size their archive declares; above it 413 payload_too_large, and so above 10 000 files. An import is read in memory, one at a time per replica (ADR 0051 D7, docs/security/import-and-export.md) |
Object storage (ADR 0016) —
the endpoint, the bucket and both keys together, or none of them. Without them uploads answer
501 uploads_disabled and the log warns at start; the other three are read only with them.
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_S3_ENDPOINT |
empty # default |
https://s3.example.com # example |
http:// or https://, a host and an optional port. Not contacted at start; the first upload is the test |
COWORK_S3_BUCKET |
empty # default |
cowork # example |
The bucket; it must exist — the backend never creates one |
COWORK_S3_ACCESS_KEY_ID |
empty # default |
cowork-app # example |
The access key's id |
COWORK_S3_SECRET_ACCESS_KEY |
empty # default |
— | Security: a secret, never echoed; the key of a policy that reaches this bucket only, never root credentials |
COWORK_S3_REGION |
empty # default |
eu-central-1 # example |
Empty lets the client ask the server |
COWORK_S3_USE_PATH_STYLE |
true # default |
true, false |
Path-style addressing, as MinIO and Silo expect; false for virtual-host style |
COWORK_S3_CA |
empty # default |
/etc/cowork/s3-ca/ca.crt # example |
A PEM file of a private authority, trusted in addition to the system's |
Event stream (ADR 0054)
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_SSE_REPLAY_WINDOW |
5m # default |
a duration, not negative | How long a replica keeps events for a reconnect's Last-Event-ID; beyond it the stream starts with resync. 0 keeps no replay |
COWORK_SSE_MAX_STREAMS_PER_PERSON |
10 # default |
a count; 0 disables |
The streams one person holds on one replica; one more closes the oldest with event: unavailable |
Three variables were renamed when a tenant became a team
(ADR 0005 D1). For this
release the backend still reads each under its name before; a later release reads the new names
only. Source: renamedVariables in config.go.
| Name before | Read as |
|---|---|
COWORK_BOOTSTRAP_TENANT_SLUG |
COWORK_BOOTSTRAP_TEAM_SLUG |
COWORK_BOOTSTRAP_TENANT_NAME |
COWORK_BOOTSTRAP_TEAM_NAME |
COWORK_ATTACHMENT_TENANT_QUOTA |
COWORK_ATTACHMENT_TEAM_QUOTA |
- Set alone, a name before is read as the variable that replaces it, and
cowork serve,cowork migrateandcowork check-consistencylog a warning at their start:a variable is set under its deprecated name; set the variable that replaces it, since a later release no longer reads the name before, withvariableandreplaced_by. An error about its value names the variable as it was set. - Set beside its replacement to the same value — the quota compared as a size, so
1GiBand1073741824are one value (sameValue) —, it is accepted without a warning: the chart sets both names for this release, so that an image rolled back to the release before, which reads only the names before, keeps the values. - Set beside its replacement to a different value, the start is refused, the error naming both variables and neither value.
(ADR 0076,
docs/operations/chat.md) — without COWORK_CHAT_PROVIDERS there is no
chat, and a limit below set without it ends the start. Each id of the list has the variables of the
second table. Source: backend/internal/config/chat.go.
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_CHAT_PROVIDERS |
empty # default |
lmstudio,claude-work # example |
The providers the person picks from, by id, comma-separated; the first is a turn's default. Security: every provider listed receives what the chat reads for its person in every team, confidential tickets included — the owner's accepted risk (H-37); list a hosted provider only where every team's data may go to it |
COWORK_CHAT_TURN_TIMEOUT |
5m # default |
a duration, not negative; 0 disables |
How long one turn — the model's calls and the tools' — may take; past it the turn ends with the error event timeout |
COWORK_CHAT_MAX_STEPS |
8 # default |
a count; 0 disables |
The calls of the model in one turn |
COWORK_CHAT_TURNS_PER_PERSON |
2 # default |
a count; 0 disables |
The turns one person runs at once on one replica; one more is 429 chat_busy |
| Variable of a provider | Default | Values | Meaning |
|---|---|---|---|
COWORK_CHAT_<ID>_NAME |
the id # default |
LM Studio # example; 1–64 characters without control characters |
What the panel shows |
COWORK_CHAT_<ID>_KIND |
— (required) | openai, anthropic |
The wire format: OpenAI Chat Completions (LM Studio, Ollama, vLLM, OpenAI) or the Anthropic Messages API |
COWORK_CHAT_<ID>_URL |
— (required) | http://localhost:1234/v1 # example, https://api.anthropic.com # example |
The base URL; /chat/completions is appended for openai, /v1/messages for anthropic; no user, query or fragment. Security: https://, or http:// only on a host of the operator's network by its name or address — the key and every turn's text travel in it (H-41); never quoted in an error |
COWORK_CHAT_<ID>_MODEL |
— (required) | qwen/qwen3-30b-a3b-2507 # example; 1–200 characters without spaces or control characters |
The model by the name its provider knows it; it is the mark's middle part |
COWORK_CHAT_<ID>_API_KEY |
empty # default; required for anthropic |
— | Sent as Authorization: Bearer (openai, where set) or x-api-key (anthropic), to that provider's host only. Security: a secret: from a Secret of its own in the chart, never echoed; the log's clip of a provider's error has it replaced (H-42) |
The frontend image takes no variable of cowork's: its nginx configuration,
frontend/nginx/default.conf, is a file in the image, nothing in it
is substituted at start, and it serves the UI and nothing else — the Ingress routes /api/ and
/auth/ to the backend. frontend.extraEnv reaches the image's entrypoint, whose own switches
(NGINX_ENTRYPOINT_QUIET_LOGS, for instance) are all it reads.
| Command | Does |
|---|---|
cowork serve |
Load the configuration (COWORK_SESSION_KEY required, the owner role's connection too while migrating on start, the identity provider's client id and secret with COWORK_OIDC_ISSUER); migrate unless COWORK_MIGRATE_ON_START=false; connect as the runtime role; refuse a runtime role that could bypass row-level security, a dirty schema and pending migrations; discover the identity provider when COWORK_OIDC_ISSUER is set, and refuse to start when it cannot; synchronise the local administrator and the bootstrap team under an advisory lock; listen until SIGINT/SIGTERM |
cowork migrate |
Load the configuration (the runtime role's connection — COWORK_DATABASE_URL or its components — names the role the migrations grant to, the owner role's is the role they run as); apply pending migrations; with COWORK_MIGRATE_BOOTSTRAP=true then synchronise the local administrator and the bootstrap team as serve does, as the runtime role and under the same advisory lock; exit 0. Exit 1 on a dirty or failing schema, a runtime role that could bypass row-level security or a failed bootstrap |
cowork check-consistency |
Load the configuration (the runtime role's connection — COWORK_DATABASE_URL or its components — and the COWORK_S3_* variables required); refuse a runtime role that could bypass row-level security, a dirty schema and pending migrations; run the consistency check of the attachments against the bucket now, as the daily job does, and print the run and every team — slug, id, dangling, accepted as lost, orphaned objects and their bytes; exit 0. Exit 1 on a failure, and when another replica runs the check at that moment. In the chart: kubectl exec deploy/<fullname>-backend -c backend -- /app/cowork check-consistency (backups) |
cowork version |
Print cowork <version> (commit <sha>, built <epoch>) |
cowork help |
Print the usage (also -h, --help) |
Exit codes: 0 success, 1 configuration or runtime error, 2 unknown command or no command.
The MCP server for Claude Code and the commands its hooks run
(ADR 0041,
ADR 0067,
ADR 0070). It is a
client of /api/v1 with the person's token and nothing else: every request carries
Authorization: Bearer, X-Cowork-Agent (the agent header of cowork-mcp)
and the User-Agent cowork-mcp/<version>, every POST an Idempotency-Key of its own, and a
failed connection is retried twice where a repetition cannot act twice. It follows no redirect.
Setup: docs/operations/claude-code.md; what it holds and leaves
open: docs/security/agent-client.md.
| Variable | Default | Values | Meaning |
|---|---|---|---|
COWORK_URL |
— (required) | https://cowork.example.com # example |
The installation, as the browser shows it; http:// only for localhost or a loopback address; no user, query or fragment. Security: the token goes to this URL and nowhere else |
COWORK_TOKEN |
— (required) | cwk_… |
A personal access token, best an agent token with write scope. An error names the variable, never the value. Security: a credential: from the plugin's sensitive option (the system's credential store) rather than a shell profile or a settings file |
CLAUDE_PROJECT_DIR |
the working directory | /home/ada/src/app # example |
The directory whose repository a session works in; set by Claude Code. A hook's own cwd comes first. It also names the file in which the SessionStart and PostModelSwitch hooks record the session's model for the server's mark; without it the server's model is unknown |
| Command | Does |
|---|---|
cowork-mcp serve |
The MCP server on stdio. At start it compares its major version with the installation's, checks that the installation serves every operation its tools use and reads the token; a refusal answers every tool call with the reason, an installation it cannot reach is asked again at the next call. Exit 1 at once on a missing or malformed variable |
cowork-mcp session-context |
The SessionStart hook: reads the hook's JSON on standard input (session_id, cwd, source, model), prints the session block — the binding or the proposal, the active ticket with its context or the top of "next for me" in the bound project by score, what happened since the last session — as plain text of at most about 9 000 bytes, and records the start (not after a compaction) and the input's model, which the server of the same CLAUDE_PROJECT_DIR puts into the mark of its acts — an input without one records none, except after /clear or a compaction, which keep the model recorded before. Nothing in a directory with no remote and no binding file, or without the two variables; one line naming the cause on a malformed variable or a failure, and the token page where a new token may help. Exit 0 always |
cowork-mcp session-end |
The Stop hook: prints {"systemMessage": "cowork: …"} when a ticket of the person is in-progress in the bound project, the repository shows work since the session started and nothing was recorded on the ticket since; nothing otherwise, also on every error and while stop_hook_active. Exit 0 always |
cowork-mcp model-switch |
The PostModelSwitch hook (Claude Code 2.1.251 or later): reads the hook's JSON on standard input (to_model, agent_id) and records to_model as the session's model, which the server of the same CLAUDE_PROJECT_DIR puts into the mark of its next act. Records nothing for an input with an agent_id — a switch inside a subagent — or without to_model, without the two variables or without CLAUDE_PROJECT_DIR; sends no request. Prints nothing on standard output, which Claude Code would add to the model's context; a malformed variable or a failed write is one line on standard error, which Claude Code keeps in its debug log. Exit 0 always |
cowork-mcp token check |
Whether the token works against the installation: its person, name, scope, restriction, capabilities and expiry; --json for the same as an object, the restriction as team and project — and tenant beside team, the same slug, deprecated, for this release. Exit 1 when it does not work, with the installation's answer and the token page |
cowork-mcp lookup |
The working directory's remotes, binding file and binding — or the proposal, or why there is none; --json for the same as an object: binding and binding_file under the names of their fields, the team as Team and, for this release, as Tenant beside it, the same slug; lookup the API's answer |
cowork-mcp export <team>/<PROJECT> <dir> |
The project's export, unpacked into <dir>, which must not exist or be empty: <dir>/<team>/<PROJECT>-<n>.md per ticket and the three manifests and no other name, written as new files inside <dir> — never over a file; on POSIX systems directories 0700 and files 0600, on Windows the access list of <dir>, so export under your profile there —; prints the count of tickets and of the confidential tickets left out. Exit 1 on a target that is a file or not empty, before anything is asked, and on the installation's refusal, 2 on a malformed <team>/<PROJECT>. Recorded as an export by the agent cowork-mcp/unknown/export (ADR 0070 D2, D5) |
cowork-mcp import <team>/<PROJECT> <path> [--dry-run] |
Imports into the project, as the token's person's agent: a directory packed with the files the import reads — its Markdown files and an export's manifest.json and links.json, named by their path under <path> as given, no link followed —, or an archive or one Markdown file as it is. Prints the dry run's report and, without --dry-run, executes it and prints the executed report: the summary, then per file its outcome, key, title, why it was skipped, excluded or left out, its links, errors and warnings with field and line. Exit 0 once answered — a file left out is in the report —, 1 on a path it cannot read or the installation's refusal, 2 on a malformed <team>/<PROJECT>. Recorded as acts of the agent cowork-mcp/unknown/import (ADR 0070 D2, ADR 0051) |
cowork-mcp version |
cowork-mcp <version> (commit <sha>, built <epoch>, API /api/v1: <n> operations) |
cowork-mcp help |
The usage (also -h, --help) |
Exit codes: 0 success, 1 configuration or runtime error, 2 unknown command, no command,
arguments a command does not take, or --json where a command takes none.
Every tool answers Markdown that names the canonical key of what it touched; api answers the
API's JSON. A refusal of the API is the tool's error with the API's status and code. Each
description names the limits the token can run into, and once the token is read, which
capabilities it holds and lacks
(ADR 0042,
ADR 0043).
A key is team/PROJECT-n, or PROJECT-n in a bound session.
| Tool | Arguments | Does | Capability |
|---|---|---|---|
session_start |
— | the session block of session-context, again; the binding it finds is the session's |
— |
get_ticket |
key, comments (10), activity (10) |
the ticket's context document and the commit strings for it; read only | — |
search |
query (optional in one project: without it, the project's tickets in rank order), scope (project, team, all; tenant, the deprecated name of team, still taken), project, state[], type[], assigned_to_me, include_terminal |
full text over titles and bodies through the ticket lists' q filter, combined with the other filters, at most 20 hits in the list's order — a project's rank, a team's newest first — not the ranked search of GET …/search; read only |
— |
file_ticket |
type, title, severity, security, effort, body, threat, parent, project, links[], horizon (later), after, before |
files a ticket in the bound or the named project into its horizon, directly after or before a ticket of that horizon or at its end, then its links | set-horizon for a horizon other than later, rank for a place |
record_state |
key, body, comment |
replaces the body as a whole with If-Match of the version it read |
— |
comment |
key, text, mentions |
comments, in the person's name with the agent's mark; mentions names the persons it mentions — me, a username, a display name of a member, or a person id — whom the API tells and makes watchers |
— |
link |
key, type, other_key |
links two tickets of a team; an existing link is success | — |
watch |
key |
sets the person's watch interest |
— |
place_ticket |
key, horizon, after, before, reason |
moves a ticket to another horizon with a reason — later clears the horizon set —, to a place directly after or before a ticket of its horizon, or both; a horizon is a planning category, not a state |
set-horizon for a horizon, rank for a place |
open_question |
key, question, options, recommendation, asked_of |
opens one question for a person (me, a username, a display name or an id; left out, the team) |
— |
record_answer |
key, question, answer |
writes down the answer the person gave in chat, marked as recorded by the agent | record-answer |
transition |
key, to, reason_or_note, block_kind, blocked_by, comment |
moves the ticket from the state it read | decide to decided, close to done, drop to dropped |
set_progress |
key, percent, stage (implementation), note, reason, comment |
sets one of the three stages | close when it closes the ticket |
finish_work |
key, verification_note |
the note as a comment, the implementation stage at 100 — unless that would close the ticket without close —, then done with close from in-progress or review, else review from in-progress; says what remains for a person and for the repository |
close for done |
create_project |
team, key, name, remote, path |
creates a project and binds the repository in one act, after the person's yes to the proposal; idempotent over the remote. tenant, the deprecated name of team, is still taken in its place; both with different slugs are refused |
create-project |
api |
method, path, body, if_match |
any route under /api/v1/ of the installation with the same token and limits; a POST gets an Idempotency-Key |
as the route needs |
session_start is for a terminal session; a host that runs the tools in the backend leaves it
out (tools.Catalogue(tools.Anywhere), mcp.md).
The contract is the OpenAPI 3.1 document in backend/api/
(ADR 0046); the backend
serves it at /api/v1/openapi.json with info.version set to its own version, and validates
every request against it. What this section says in one line per route, the document says in
full.
- Authentication. Every route under
/api/v1/exceptversion,openapi.jsonandschemas/cowork-yaml.jsontakes one of two credentials, and the document says which per operation (bearerToken,sessionCookie). A personal access token,Authorization: Bearer cwk_…, is for scripts and agents; the session cookie__Host-cowork-sessionof a browser login —POST /auth/local, or the identity provider'sGET /auth/callback— is for the browser. A request with anAuthorizationheader is a token's, whatever cookie it carries. Without a valid credential the answer is401(unauthenticated,token_expired,token_revoked, andnot_allowedfor a token whose person the identity provider's gate no longer admits) withWWW-Authenticate: Bearer realm="cowork". Nineteen routes take a session only and answer a token403 session_required: creating a token, a team or a local account, resetting or changing a password, unlocking a local account, logging out, a turn of the chat and stopping one, choosing the chat's capabilities, a global administrator's list of every team, purging a deleted ticket, removing the orphaned objects of a consistency check, and the administration acts that can give access — adding a member, setting a grant, making or changing a group mapping, restricting or opening a project, putting a person on its access list (ADR 0035 D5, ADR 0024 D7, ADR 0059 D4, tokens). A token is403 session_requiredas well on three acts in their giving direction: a team settings change that switchestime_visible_to_membersormembers_create_projectson or movestime_locked_untilearlier or clears it, lifting the confidential flag, and assigning a confidential ticket to anyone but the token's person or the assignee it had (tokens). A write of a session must come fromCOWORK_BASE_URL— itsOrigin, or without one itsReferer— and carryX-Requested-With: cowork, else403 csrf; a token's writes need neither (CSRF). The five reads that record an act — an attachment's bytes, a ticket's…/markdownand…/context, a project's and the team's export — answer a session's request whoseSec-Fetch-Siteissame-siteorcross-sitewith403 csrf;same-origin,noneand no header pass, and a token's request is not looked at (CSRF). A session's idle clock moves on every request of it, the event stream's included, but a write that check refuses (sessions). A session whose account has a temporary password can only readGET /api/v1/me, change the password and log out; everything else is403 password_change_required.X-Cowork-Agent: <name>/<model>/<session>marks a request as an agent's; a token with the agent flag makes it one with or without the header. - Who made an act. The person is the actor of every act, through a token too. An act made
through a token carries the token —
token,{"id","name"}, the name as the token has it, kept after a revocation, never its secret — beside the agent mark: an act of the activity, a comment and a revision of it, a file, a time entry and a revision of it, a stake (agent,token), a question (asked_by_token,answered_by_token), the ticket's filing (reporter_agent,reporter_token);nullfor a browser session. Whoever reads the act reads the token's name; the activity of an act recorded before the name was kept has"name": null. The context document saysthrough the token <name>where it saysvia <agent>of an agent's act, and the team's audit view carriestoken_namebesidetoken_idin JSON and as the last column of its CSV (ADR 0036 D6, tokens). - Teams. A route under
/api/v1/teams/{team}answers404 not_foundalike for an unknown slug, a team the person does not belong to and a token restricted to another. A global administrator who holds no role in the team reaches, in a browser session noX-Cowork-Agentmarks, its administration —GET …,GET …/members(without addresses),GET …/group-mappings— and the grant of a role to themselves,PUT …/members/{their id}/grant; every other route answers them that404too (tenancy). - The names before. A team was called a tenant until this release, and
/api/v1keeps the names before beside the new ones for this release, deprecated, so that a client of the release before — acowork-mcpof 0.14 among them — keeps working (ADR 0005 D1, ADR 0046 D7)./api/v1/tenantsand every path under/api/v1/tenants/{tenant}are in the document as deprecated twins of/api/v1/teamsand/api/v1/teams/{team}/…(tagtenants), and the backend answers a twin by reading its path as the team path before it finds the route: the same credentials, team boundary, handler and answer — a problem'sinstanceand the metrics'routename the team path, the request log the path as it was sent. A twin keeps the operationId of the release before where the rename renamed it (listTenants,createTenant,getTenant,updateTenant,exportTenant,searchTenant,listTenantTickets,listTenantTime,listTenantTokens,revokeTenantToken), every other carries its team path's withDeprecatedappended;searchMyTenantsof/api/v1/me/search, a path the rename kept, issearchMyTeamsand has no twin. Beside each new name its old one: the query parametertenantbesideteamon the person-level lists — both together are400 validation_failedatquery:tenant, whatever the values —, the propertiestenant,tenantsandrestricted_tenantbesideteam,teamsandrestricted_teamwith the same value in every answer,tenantin a token's creation taken asteam— both with different slugs400 validation_failedat/team—, andgroup_by=tenantbesideteam. A value that cannot carry the new word beside the old one keeps the old one in this release: the problem codetenant_slug_taken, a proposal's reasononly-tenant, the audit record'sentity_typetenantand its payload keytenant. A later release removes the names before. - Writes. An entity's
ETagis its version; an overwriting write needs it inIf-Match(428without,412with a stale one). AnIdempotency-Key(a UUID) makes a creatingPOSTsafe to retry for 24 hours — the same request replays the stored answer, another one is422; an agent's creatingPOSTmust carry one. - Lists.
limit(default 50, clamped toCOWORK_MAX_PAGE_SIZE) andcursor, from the previous page'snext_cursor. The ticket lists, the team's time entries, the audit record, the members, the person's tokens, the team's tokens and the projects also take numbered pages,pageandper_page(25,50,100;50without it, clamped likelimit), answered withtotal,pageandper_page, up to row 10 000 — not together withcursororlimit; the ticket lists, the projects, the members, the group mappings, a project's access list, the lists of a ticket — comments, activity, questions, links, interest, attachments, time entries, the prerequisite tree —, the person's inbox, "next for me", assigned tickets, decisions and projects, the bin of deleted tickets, the saved filters, the team's dashboard, the team's tokens, the attachments' usage and their consistency check answer a weakETag, the caller's page, and304without a body to it inIf-None-Match. A query parameter the route does not declare is400; a path parameter that cannot name anything is404. The person-level lists under/api/v1/me/— the inbox, "next for me", the tickets assigned to the person, the open decisions, the search, the projects — span every team of the person, name the team on every item asteam, and astenantbeside it for this release — but the projects, a list newer than the rename, which name it asteamalone —, taketeam=<slug>— or the deprecatedtenant=<slug>, never both, but on the projects — to narrow to one (404 not_foundfor a slug that names none of theirs, as for an unknown one) and carry cursors only; a token restricted to a team or a project reads that team or that project alone. The lists of tickets follow the score (ADR 0014 D5), highest first. - Every response carries
Cache-Control: no-store(the event streamno-cache) andX-Request-Id. An error isapplication/problem+json(RFC 9457):type,title,status,detail,instance, the stablecode(snake_case, the table below), therequest_idand, for invalid fields,errors[]with apointereach — for example{"type":"https://cowork.dev/problems/not-found","title":"Not found","status":404,"detail":"no such team","instance":"/api/v1/teams/acme/projects","code":"not_found","request_id":"0199a3c2-1d2e-7f00-8000-0000000000ff"}.
| Method and path | Answers |
|---|---|
GET /healthz |
200 {"status":"ok"} — the process serves; no authentication |
GET /readyz |
200 {"status":"ready"}, or 503 not_ready when the database does not answer (the error goes to the log); no authentication |
GET /api/v1/version |
200 {"version":"…","commit":"…","build_time":"…"}; no authentication |
GET /api/v1/openapi.json |
the API document; no authentication |
GET /api/v1/schemas/cowork-yaml.json |
the JSON Schema (draft 2020-12) of a repository's .cowork.yaml: team and project required — tenant, the deprecated key before, in place of team or beside it —, path and url optional, nothing else; no authentication |
| a known path with another method | 405 method_not_allowed, Allow names the methods the API document declares there; the document declares no HEAD, so HEAD on the API is 405 (the health endpoints answer it) |
| any other path | 404 not_found, detail: no route <METHOD> <path> |
GET /auth/options |
200 {"local": bool, "oidc": bool, "oidc_name": string or null, "password_min_length": int, "token_max_lifetime_days": int} — what the login page offers: the local form while an active local account exists; the identity provider's button while one is configured and its gate names a group, with its name (COWORK_OIDC_DISPLAY_NAME; null without a provider); the minimum password length every password form follows (COWORK_PASSWORD_MIN_LENGTH); and the longest lifetime of a new token in whole days, rounded down (COWORK_TOKEN_MAX_LIFETIME; 0 under a day), the bound of the token form; no authentication |
GET /auth/oidc/login?return_to=<path> |
a browser navigation: 302 to the identity provider's authorization endpoint for the code flow with PKCE (S256), a state and a nonce, setting the state cookie __Host-cowork-oidc for ten minutes; 303 to /login?error=oidc_unavailable without a provider or with a gate that admits nobody, with &return=<path> when return_to names another path than /. return_to is a path of this installation — one leading /, no //, /\, backslash, byte below 0x20 or DEL, at most 2048 bytes — or /; silent=true (default false) is the login page's own sign-in after a session ended: the authorization request carries prompt=none, so an issuer that holds the person's session answers with a code and one that does not with an error, which the callback turns into login_required; no authentication |
GET /auth/callback |
the provider's return, the redirect URI: the state cookie (at most ten minutes old) and its state, the code redeemed with the PKCE verifier, the ID token verified, the groups read and judged by the gate, the person found by issuer and subject or made, their memberships derived from the teams' group mappings, the session made; 303 to return_to with the session cookie, or 303 to /login?error=<code>&return=<path> with oidc_failed, login_required — the issuer's error to a silent start, whatever it says —, not_allowed, not_initialised or oidc_unavailable — the state cookie cleared either way. Parameters the provider adds (iss, session_state) are taken and not read; no authentication |
POST /auth/local |
{"username","password"} → 200 {"password_change_required": bool} and the session cookie; every failure is 401 invalid_credentials, the same answer in the same time for an unknown username, a wrong password, a locked or a deactivated account; 429 too_many_attempts from the address throttle; 403 not_initialised for a person who is not a global administrator while no team exists; 403 csrf unless the Origin is COWORK_BASE_URL; no authentication |
POST /auth/logout |
a session, CSRF-checked: ends it, clears the cookie, 204 — or, for a session of the identity provider whose discovery names an end_session_endpoint, 200 {"end_session_url"}: that endpoint with client_id and post_logout_redirect_uri = COWORK_BASE_URL + /login, for the browser to go to; cowork does not call it |
GET /api/v1/me |
the calling person and their memberships — each with the effective role and its origins, mapping and grant with their own roles, and can_create_projects, whether that role lets them create a project in the team: an administrator always, a member while the team's members_create_projects is on, a viewer never (a token's scope and an agent's capabilities are the creation's own check) — whether they are a global administrator (global_admin), have a local account (local) and must change a temporary password (password_change_required) |
PUT /api/v1/me/password |
a session only: {"current_password","new_password"}; the current password counts like a login attempt towards the lockout and the address throttle (429 too_many_attempts); the new one meets COWORK_PASSWORD_MIN_LENGTH and differs; every other session of the account ends; 204. Not for the local administrator, whose password is the configuration's, nor for a person of the identity provider, who has none (403 forbidden) |
GET /api/v1/me/tokens |
the person's tokens, newest first, revoked and expired ones included, numbered pages with a total — metadata only: a restriction names its team by slug (restricted_team, and restricted_tenant beside it, deprecated) and its project by key (restricted_project, null where the person no longer sees the project or belongs to its team — the token reaches nothing then); restricted_project_id, the project's id, is deprecated and kept in /api/v1 |
POST /api/v1/me/tokens |
a session only: {"name","scope"} and optionally agent, capabilities (decide, close, drop, rank, set-horizon, interest, upload, create-project, record-answer), team (or tenant, its deprecated name, taken as it; both with different slugs are 400 validation_failed at /team), project, lifetime_days; 201 with the token and its plaintext, once — a replay for an Idempotency-Key answers without it. The lifetime defaults to COWORK_TOKEN_DEFAULT_LIFETIME and is shortened to COWORK_TOKEN_MAX_LIFETIME; an agent token has at most write scope and every capability when capabilities is left out — an empty list is none, the baseline only. The name shows on every act made through the token, to whoever reads the act |
DELETE /api/v1/me/tokens/{token_id} |
revoke one; a token may always revoke itself, another needs write, an agent revokes only its own |
GET /api/v1/me/token |
the token the request presents: its metadata as the list shows it, and request — whether the request is an agent's, the agent its acts record and the capabilities it holds; a browser session presents none, 404 not_found |
GET /api/v1/me/chat |
the capabilities the person gives the chat in the UI: {"capabilities": [...], "chosen": bool} — chosen false is the default, every capability but decide, close, drop and record-answer |
PUT /api/v1/me/chat |
a session only, never an agent-marked one: {"capabilities": [...]}, the whole set, unique — empty leaves the chat the baseline; 200 with the set in the catalogue's order; the chat's next request holds it; no If-Match; a change is the person's recorded act |
GET /api/v1/me/repositories/lookup |
remote (1–10, repeatable, in order of preference) and path → status bound, ambiguous or unbound; the remotes with their identities (null for one that names no host); the bindings of the first remote that has one covering path, in the projects the caller sees across the person's teams — a restricted token's only; for unbound a proposal (identity, name, the team — team, and tenant beside it — and the reason only-tenant, remote-owner or choose, a free key per team — teams, and tenants beside it) or proposal_unavailable saying why not. A remote's credentials are dropped, and a proxy's log may still carry the query |
GET /api/v1/me/inbox |
the person's notifications across their teams, newest first: {"items": [...], "next_cursor", "unread"}, each item id, team {slug, name} — and tenant beside it, the same, deprecated —, ticket {key, title, state} as it is now, reason — assigned, asked, answered, state_changed, blocker_closed, commented, urgent, mentioned —, act (the act it renders from, as the ticket's activity shows it, without its payload where it names a ticket the person cannot see), blocker (for blocker_closed, the ticket that blocked it, as it is now — null for one of another team, which the act names in its refs alone and the ticket's relations show by its head), withdrawn (the comment or question has been withdrawn since), read, created_at; unread counts the unread ones. A notification of a ticket the person no longer sees, or of a team they left, is absent and counts nowhere. team (or the deprecated tenant), limit, cursor |
PUT /api/v1/me/inbox/read |
{"through": "<notification id>"}: every unread notification of the person up to and including that one, read — one that arrived after it stays unread; team (or the deprecated tenant) narrows; write scope; 200 {"unread"}; one act read per team where something changed |
PUT /api/v1/me/inbox/{notification}/read |
one notification read; write scope; 200 {"unread"}; one already read records nothing; another person's, or one of a ticket the person no longer sees, 404 not_found |
GET /api/v1/me/next |
"next for me": the open tickets — neither done nor dropped — assigned to the person or to nobody, in the projects they see across their teams; another person's is not in it: {"items": [{"team": {slug, name}, "tenant": {slug, name}, "ticket": {...}, "place"}], "next_cursor"} — tenant the same as team, deprecated —, by the score, highest first — a ticket without one last — then by the ticket's id; place is the ticket's place in its project's rank among the open tickets of its horizon the caller sees, 1 for the first; team (or the deprecated tenant), project (a project key within that team, which it needs: 400 without), limit, cursor |
GET /api/v1/me/assigned |
the open tickets — neither done nor dropped — assigned to the person across their teams: {"items": [{"team": {slug, name}, "tenant": {slug, name}, "ticket": {...}, "place"}], "next_cursor"}, by the score as /me/next is; team (or the deprecated tenant), limit, cursor |
GET /api/v1/me/decisions |
the open questions asked of the person and those open in their teams — asked of nobody —, on tickets they see: {"items": [{"team", "tenant", "ticket": {key, title, state}, "question": {...}}], "next_cursor"}, by the score of the ticket — done and dropped tickets after the others — then the ticket's id and the question's number; team (or the deprecated tenant), limit, cursor |
GET /api/v1/me/projects |
every project the person sees in each of their teams — a restricted project only to the team's administrators and the people on its list, an archived one to nobody, nothing of a team a global administrator holds no role in —: {"items": [{"team": {slug, name}, "project": {...}}], "next_cursor"}, by the team's slug, then the project's key; what the UI's sidebar lists. One read per team; a weak ETag and 304; team narrows to one of the person's teams (no deprecated tenant: the route is newer than the rename), limit, cursor; a restricted token reads its team or its project alone |
GET /api/v1/me/search |
the search of GET …/search below over every team of the person — one read per team, merged by rank, each hit naming its team: q (required, white space alone is 400 validation_failed), team (or the deprecated tenant), limit, cursor; a restricted token searches its team or its project alone |
GET /api/v1/teams |
a global administrator, session only: every team of the installation by slug, {"slug","name","role"} — role the caller's, the higher of mapping and grant, null where they hold none; limit and cursor. Anybody else 403 forbidden; a token 403 session_required |
POST /api/v1/teams |
a global administrator, session only: {"slug","name"} → 201; the creator becomes the team's first administrator by a marked grant, in the same transaction; 409 tenant_slug_taken, a code that keeps the name before |
GET /api/v1/tickets/{team}/{key} |
a ticket by its short key, <PROJECT>-<number> — the body and ETag of its own route |
The routes of a team start with /api/v1/teams/{team}, written … below; …/{number}
stands for /api/v1/teams/{team}/projects/{project}/tickets/{number}. Each of them, and
/api/v1/teams above, is served under /api/v1/tenants as well for this release, as its deprecated
twin (the names before).
Local accounts — 6 routes
For the team's administrators with admin scope, never an agent. Creating an account,
resetting a password and unlocking an account take a browser session only — a token, an
administrator's included, is 403 session_required — because an account or a password made with a
leaked token would outlive its revocation, and an unlock with one would keep the lockout from ever
holding; the other three routes work with an admin-scope token. A team manages the local
accounts its own administrators created and no others: another team's account, a global
administrator and the local administrator answer 404 not_found, like a username nobody has.
An administrator's own account is off limits for a password reset, an unlock and a deactivation
(403 forbidden).
| Method and path | Does |
|---|---|
GET …/accounts |
the accounts the team manages, by person id: username, display name, role, password_change_required, locked, deactivated_at |
POST …/accounts |
a session only: create one: {"username","display_name","temporary_password","role"}; the person changes the password at the first login; a marked grant with that role; 409 username_taken (the installation has one namespace) |
PUT …/accounts/{username}/password |
a session only: set a new temporary password; every session of the account ends |
DELETE …/accounts/{username}/lockout |
a session only: forget the failures and the lock of the username |
PUT …/accounts/{username}/deactivation |
deactivate: no login, tokens revoked, sessions ended, the person and their acts stay; 409 last_admin, changing nothing, when the team would be left without an administrator who can log in — the deactivation takes the team's lock, so two administrators who deactivate each other at once are decided one after the other; the other teams the person administers are not asked |
DELETE …/accounts/{username}/sessions |
end every session of the account, at once; tokens are not affected |
Members, their tokens, group mappings and project access — 14 routes
For the team's administrators, never an agent, unless a row says otherwise — a global
administrator who holds no role in the team reads the members and the mappings and grants a role
to themselves, and one who holds a role below admin raises their own grant. Every act that can give access takes a browser session only — a token, an administrator's included, is
403 session_required — because what it gives would outlive a leaked token's revocation; removing a
grant, a mapping or an access entry works with an admin-scope token as well. A change of a grant or
of a mapping that would leave the team without an administrator who can log in — mapped or
granted, active, and a local account or a person of the configured issuer whom the gate admitted at
their last login, refresh or check — is 409 last_admin and changes nothing; these changes take the
team's lock, so two at once are decided one after the other. Every change is recorded and
announced on the event stream as membership.changed.
| Method and path | Does |
|---|---|
GET …/members |
every member reads it, and a global administrator without a role in the team, in a session: the members by person id, each with the effective role — the higher of the mapped and the granted one — every origin with its own role (mapping, grant), local for a person with a local account, and email: the person's address for the team's administrators, null for everyone else and for a person without one; numbered pages with a total |
POST …/members |
a session only: {"person","role"} grants a role to a person who exists — an e-mail address, compared without regard to case with the address the identity provider asserted at the person's last login — only one it marked verified, or, with COWORK_OIDC_EMAIL_TRUSTED=true, one it said nothing about; never one it marked unverified — among the persons of the configured issuer, or a local account's username, with or without local:; 201 with the member; 404 person_not_found, 409 person_ambiguous (an address several persons share), 409 grant_exists; takes an Idempotency-Key |
PUT …/members/{person_id}/grant |
a session only: {"role"} creates the member's grant or changes its role; the mapped membership is never touched; 404 person_not_found for a person who is no member. A global administrator who does not hold admin in the team — no role there, or a lower one — sets their own grant here, their own id and any role: a marked grant made or its role changed, recorded in the team with them as its actor, never 409 last_admin; a grant to anybody else is 403 forbidden |
DELETE …/members/{person_id}/grant |
removes the grant; a mapped membership stays; 204, also when there was none |
GET …/group-mappings |
read scope, and a global administrator without a role in the team, in a session: the mappings by group, each with includes_caller — whether the caller's own groups, as of their last login or refresh, hold it |
POST …/group-mappings |
a session only, of a global administrator who administers the team — any other administrator is 403 forbidden, and nothing is written, because every team shares the provider's groups: {"group","role"}, the group as the provider's claim carries it, case and all; the memberships of every active person of the configured issuer behind the gate whose groups hold it are derived at once — whoever holds the group joins the team; 201 with ETag and Location; 409 mapping_exists; takes an Idempotency-Key |
PATCH …/group-mappings/{mapping_id} |
a session only, of a global administrator who administers the team (403 forbidden otherwise): {"role"} with If-Match; the memberships follow at once |
DELETE …/group-mappings/{mapping_id} |
any administrator of the team: removes it; the memberships it derived go, or fall to the person's other mapped groups; grants stay; 204, also when there was none |
GET …/tokens |
read scope: the tokens that can act in the team — every token of a member that is unrestricted or restricted to this team, revoked and expired ones included — newest first, each with its person, name, scope, agent, capabilities, restricted_team (this team's slug, null for an unrestricted token; restricted_tenant beside it, the same, deprecated), restricted_project, dates, last_used_on (for an unrestricted token wherever it was used) and state; metadata only, never a secret; numbered pages with a total; a weak ETag, 304 to it. A token restricted to another team, and a token of a person who is no member, are not there — not even by name |
DELETE …/tokens/{token_id} |
revoke one of that list: immediate and final, recorded in the team's audit as revoked with the person, the name and whether it was unrestricted — an unrestricted token ends in every team of its person; an admin-scope token may; 404 not_found for a token the list does not show; 204, also when it was revoked already |
PUT …/projects/{project}/restriction |
a session only: {"restricted": bool} with the project's If-Match; a restricted project is visible to the team's administrators and the people on its access list, and to nobody else |
GET …/projects/{project}/access |
read scope: the project's access list by person id, each entry member or viewer, with the person's email |
PUT …/projects/{project}/access/{person_id} |
a session only: {"role"}, member or viewer, puts a member of the team on the list or changes their entry — their role in the project is the lower of their team role and the entry; 404 person_not_found for a person who is no member. The list may be written before the project is restricted |
DELETE …/projects/{project}/access/{person_id} |
takes a person off the list; 204, also when they were not on it |
The chat in the UI — 3 routes
Every member of the team (ADR 0076, docs/developer/chat.md).
| Method and path | Does |
|---|---|
GET …/chat |
whether the team's members may chat: {"available", "providers": [{"id","name","kind","model"}], "reason"} — the providers in the configured order, never a URL or a key; reason is not_configured without a provider, null otherwise; a token as well |
POST …/chat |
a session only, CSRF-checked: one turn, {"conversation","messages"} and optionally provider (an id of the list; the first when left out; another is 400 validation_failed) and context (the page); 200 text/event-stream with the events text, tool_call, ui, tool_result, error and done last, which carries the messages to append and the reason answered, step_limit, stopped or error. Every tool call runs at once as the person's agent, chat/<model>/<conversation>, with the person's chat capabilities; 409 chat_unavailable without a provider, 429 chat_busy past COWORK_CHAT_TURNS_PER_PERSON |
DELETE …/chat/turns |
a session only, never an agent-marked one, CSRF-checked: ends every running turn of the session's person in the team on the replica that answers, at once; 204 once they have ended, or after five seconds; nothing running is no error. Another replica's turns are not reached (H-48) |
Team and projects — 13 routes
| Method and path | Does |
|---|---|
GET … |
the team and its settings; also to a global administrator without a role in the team, in a session |
PATCH … |
change the name or the settings — an administrator with admin scope, never an agent; If-Match; a change that switches time_visible_to_members or members_create_projects on, or moves time_locked_until earlier or clears it, takes a browser session (403 session_required for a token) |
GET …/audit |
the audit record, newest first, for administrators, each act with token_id and the token's name, token_name; filters actor, token, action, entity_type, from, to; numbered pages with a total; CSV on Accept: text/csv, token_name its last column, after the columns released before — a CSV page carries no cursor, so a client reads several as numbered pages with to held at the moment it began |
GET …/dashboard |
the team's dashboard, its nine tiles over what the caller can see — a ticket, a question or a time entry the caller may not read counts nowhere, and neither does a deleted ticket until it is restored: the open tickets per project and state; per severity, every one with zero included; the open live and boundary findings, each with the oldest by filing; the blocked tickets and the one blocked longest — since its latest act into blocked — with its kind; the open tickets' age in five buckets, under 7, 30, 90 and 365 days and older; the tickets done per ISO week, eight weeks to the one of the period's last day; the median from filing to done of the tickets done in the thirty days to that day, with their count; the open questions and the one asked first; the minutes booked in the period per project. Beside them the eight open tickets updated last. project as the ticket lists take it (repeatable, ! leaves one out; without a plain value every project that is not archived), from and to, days inclusive, by default the thirty days that end today, UTC; from after to is 400. Open tiles stand as the tickets do at the request. A weak ETag and 304; not for a token restricted to a project |
GET …/events |
the event stream of the changes the caller may see (runtime.md) — project.changed {"key": "<team>/<PROJECT>", "kind"} among them, a project's rank sorted by the score; with me=true the person-level stream: every event of every team the person belongs to that the team's own stream would carry to them, each with its id: and replayed across the teams after a reconnect, and inbox.changed {"unread": n} — when it opens and whenever the person's inbox changes, without an id:; a token restricted to a team hears its team alone. membership.changed names its team: {"team", "tenant", "person_id", "project_id", "mapping_id"} — tenant the same slug as team, deprecated, for this release —, each key but team and tenant where it applies |
GET …/projects |
the projects the caller can see, by key; include_archived; numbered pages with a total |
POST …/projects |
create one — write; a member while the team allows it, an administrator always, an agent with create-project. With repository (remote, optionally path) the repository is bound in the same act, and when a project of the team binds it already the answer is 200 with that project and nothing is created — 409 repository_bound when the caller cannot see it |
GET …/projects/{project} |
one project |
PATCH …/projects/{project} |
change its name, its description or its advisory WIP limits per state — analysed, decided, in-progress, review, blocked — a member with write, an agent too; If-Match |
PUT …/projects/{project}/archive |
archive it — an administrator, never an agent; it keeps its tickets and refuses new ones |
GET …/projects/{project}/repositories |
the repositories it owns, in the order they were bound: identity, path, remote as last given without credentials |
POST …/projects/{project}/repositories |
bind one, {"remote"} and optionally path — those who may create a project, judged by the role in it; 201 new, 200 when the project binds it already, its remote updated to the form given; 409 repository_bound when another project of the team binds it, naming that project only to a caller who sees it |
DELETE …/projects/{project}/repositories/{repository} |
unbind it — the same people; 204 also when the binding is gone; the project and its tickets stay |
Tickets — 29 routes
| Method and path | Does |
|---|---|
GET …/tickets |
the team's tickets across its projects, newest first; filters project, state, type, severity, security, horizon, effort, assignee, reporter, parent, progress_min, progress_max (the implementation stage), opened_after, opened_before, updated_after, updated_before, done_after (done after it — like the four timestamps before it, the bound itself excluded), q (every word in the title and body, a filter in the list's order, no rank and no snippet), include_terminal, blocked, has_open_questions, interest (ADR 0049) |
GET …/search |
search the team's tickets (ADR 0025): q (required) by full text — the simple dictionary after unaccent, every word in one text — over the title and body, the comments but a withdrawn one, the questions and the file names; a key by its beginning (COW-1, acme/COW-12), a title by trigram. {"items": [...], "next_cursor"}, one hit per ticket at its best match, the best first — a key, the title, the body, then comments, questions and file names, a title by trigram last: team (and tenant beside it, deprecated), key, title, type, state, found_in (key, ticket, comment, question, attachment), comment (its id) or question (its number) where it matched, and snippet, [{"text","match"}], the found words marked — text, never HTML. What the caller cannot see is not searched, snippets included; limit, cursor — a cursor belongs to its query; a project-restricted token searches its project |
GET …/projects/{project}/tickets |
the project's tickets in its rank: the ranked by their key, then the unranked — done and dropped, and open ones a release before the rank filed — by number (ADR 0014); the same filters but project; a cursor from before the rank is 400 invalid_cursor |
POST …/projects/{project}/tickets |
file a ticket (type, title, severity, security, effort); its number is the project's next. horizon is its horizon, later when left out; after or before names an open ticket of that horizon it is placed directly next to, the bottom of the rank — the end of its horizon — when left out (ADR 0010 D3, ADR 0014 D2); an agent needs set-horizon for a horizon other than later and rank for a place, and assigns a confidential filing — security live or boundary — only to its own person (403 agent_forbidden otherwise); so does a person's token (403 session_required otherwise: admitting another person takes a browser session). parent takes a canonical key of any team the caller reads a ticket of, or a short key of the team, any project (ADR 0008 D2); a key the caller cannot read — whatever the reason — is 400 validation_failed, "no such ticket" at /parent, as one that names nothing |
GET …/{number} |
one ticket: its state — filed, analysed, decided, in-progress, review, blocked, done, dropped —, its horizon and horizon_set — {value, reason, by, at} of the horizon a person or an agent set, null for a ticket nobody placed, which stands in later (ADR 0010 D1, D3), its three progress stages progress_refinement, progress (implementation) and progress_review, derived from its children while it has any, done_from and done_by_hand while it is done, open_prerequisites, the open tickets that block it directly whose state the caller reads in a head, of any team — never a confidential one the caller is not admitted to —, parent, the parent's canonical key — null for none and for a placeholder —, parent_head, the parent as the caller sees it: team (slug, name), key, title, type, state, placeholder and readable — a ticket of another team, or of a project restricted from the caller, by this head alone, a confidential one the caller is not admitted to as <team> [Confidential], its key, title, type and state null (ADR 0005 D3) —, and score with score_version — the weights of the severity (critical 8, high 5, medium 3, low 1, cosmetic 0), the horizon (now 8, release 5, next 3, later 1, icebox −5) and the stakes (need 1, urgent 2), plus one for every thirty days since opened_at, to one decimal at the moment of the read; null while the ticket is done or dropped (ADR 0014 D4) |
PATCH …/{number} |
change its fields; If-Match. A stage takes 0–100 in steps of five in every state but dropped, never on a ticket with children. The change that brings the last of the three stages of a ticket without children to 100 is the done act: it needs note, the verification, and over open prerequisites it is 409 open_prerequisites unless a person sends override_prerequisites with a reason; an agent needs close and a ticket in in-progress or review. The change that lowers a stage of a ticket done by its stages reopens it to done_from with a reason, ranked at the bottom; a ticket done by hand stays done while its stages change; an open ticket whose three stages are full already — a parent whose last child left — is closed by hand. An agent assigns a confidential ticket — or one the change makes confidential — only to its own person or to nobody; another new assignee is 403 agent_forbidden, and for a person's token 403 session_required. parent as the filing takes it, null clearing it whatever the caller reads of the parent — a parent of another team the ticket leaves records detached in its team's record (ADR 0008 D2); a parent that is the ticket or one of its descendants, in any team, is 409 parent_cycle. The compare-and-set covers the parent the change read beside the version: a child detached from its parent's side keeps its version, and a change that raced the removal is 412 with the values as they stand, never writing the parent back (ADR 0050 D1) |
GET …/{number}/body |
its body, {"body","body_html","version"}: the Markdown as written and the HTML rendered and sanitised on the server — raw HTML shown as text; a link only to an http, https, mailto or relative address, with rel="noopener noreferrer nofollow" and target="_blank"; an image only of one of the ticket's own raster attachments, from that attachment's path, any other image a link to its address. The ETag is the ticket's (rendered Markdown) |
PUT …/{number}/body |
replace its body as a whole; If-Match |
PUT …/{number}/horizon |
set the ticket's horizon, {"value", "reason"} — now, release, next, later or icebox, a planning category independent of the state, which holds until a person or an agent sets another; later clears the horizon set, and on a ticket with none set changes nothing. The reason is optional for a person, kept with a horizon set and recorded on the act either way, and required of an agent — for later too —, which needs set-horizon; If-Match, a 412 naming the current horizon and horizon_set |
PUT …/{number}/confidential |
set or lift the confidential flag — an administrator with admin scope, never an agent; lifting needs a reason and a browser session (403 session_required for a token); If-Match |
DELETE …/{number} |
delete it into the team's bin — an administrator with admin scope, never an agent (403 agent_forbidden, hard-off: deleting, restoring or purging); 204. From then on it answers like a missing ticket everywhere but the bin: every route of it and under it 404, absent from every list, tree, person-level list, inbox and context, its links hidden, its parent's derived stages without it; its key stays taken. Not refused when other tickets depend on it; a ticket already deleted is 404 |
POST …/{number}/transitions |
move it to another state: forward one step to review, back with a reason, into blocked and out to where it came from, dropped with a reason and back to filed; from must be the current state, else 409 state_conflict. To done is done by hand — from any open state for a person, from in-progress or review for an agent with close — with a verification note, and over open prerequisites it is 409 open_prerequisites unless a person overrides with a reason; done → done_from with a reason withdraws it, unless it has no children and its three stages are full, when the ticket stays done by them; a ticket done by its stages leaves done only by a lower stage. An agent needs decide, close or drop for those moves; done and dropped take the rank away, leaving them ranks the ticket at the bottom |
PUT …/{number}/rank |
place it directly after or before another open ticket of the project, {"after": n} or {"before": n}: one key written between the neighbour's and the next one's on that side, those the caller cannot see counted, recorded as ranked with the neighbour, the version raised — the key itself is never shown, the list's order is the rank; a ticket already there among those the caller can see is 200 unchanged; no If-Match — the last move wins; an agent needs rank; a done or dropped ticket or neighbour is 409 state_conflict, a neighbour the caller cannot see the 400 of one that does not exist. Before a gap between two keys runs out, the project's keys are spread again, every ticket keeping its place, with no act and no version |
PUT …/projects/{project}/rank |
"sort by score": {"by": "score"} — the open tickets of the project the caller sees take the places they hold among themselves in the score's order, highest first, each scored anew, so every horizon and every parent's children read by score; a ticket the caller cannot see keeps its key and its place. 200 {"moved", "score_version"}; one act ranked on the project naming the tickets it moved, whose activity shows it, published as project.changed; each ticket it moved gets a new version; moved: 0, and nothing recorded, when the rank follows the score already. A member, write scope; an agent needs rank; no If-Match (ADR 0014 D3) |
GET …/{number}/relations |
its parent, its children and its links, of any project or team, in that order (ADR 0005 D3, ADR 0012 D2): {"items": [...], "next_cursor"}, each kind (parent, child, link), id — the relation's id for its removal: a link's id, a child's opaque handle, which shows no ticket id, null for the parent —, link — id, type, direction, name read from this side, created_by, created_at; null for a parent or a child — and head, the other end as the caller sees it, as parent_head above; a deleted ticket is absent. kind narrows it (repeatable), limit, cursor (sealed), If-None-Match |
GET …/{number}/links |
its links in both directions inside the team, among the ends the caller sees; deprecated, replaced by …/relations |
PUT …/{number}/links/{type}/{other} |
link it, as the source, to other (a short key of the team): blocks, relates-to, duplicates, found-in; 201 new, 200 existing; a blocks cycle, through any team, is 409 link_cycle; an other the caller cannot read is 404 not_found "no such ticket", as one that does not exist |
PUT …/{number}/links/{type}/{other_team}/{other} |
link it, as the source, to a ticket of any team by that team's slug and the ticket's short key, answering the relation as …/relations lists it; the link lives in this ticket's team, and its act on the other ticket is recorded in that team's record; otherwise as the route above |
DELETE …/{number}/links/{type}/{other}, DELETE …/{number}/links/{type}/{other_team}/{other} |
remove its link to other — a write on this ticket, which may be the link's source or its target, whatever team keeps the link and whatever the caller reads of the other end (ADR 0012 D2); the link this ticket is the source of first; its act on the other ticket in that team's record, as system:relation where the caller holds no role there; 204 also when there was none |
DELETE …/{number}/links/{link} |
remove a link of it by the link's id, the id …/relations gives, from either end and whatever team keeps it — the way to remove one whose other end is a placeholder; 204; a link that does not touch this ticket, or none, 404 not_found "no such link", the same body whatever the reason |
DELETE …/{number}/children/{child} |
detach a child of any project or team from it, by the id …/relations gives the child's relation — a write on this ticket, whatever the caller reads of the child (ADR 0008 D2); the child's parent alone changes, its version stays, and this ticket's progress is derived again; updated on the child in its team's record — system:relation where the caller holds no role there — and detached on this ticket; 204; no such child of this ticket 404 not_found "no such child", the same body whatever the reason |
GET …/{number}/prerequisite-tree |
its prerequisite tree across teams (ADR 0012 D6): the tickets that block it, what blocks those, and so on, eight levels deep, depth first; direction=up reads it upward, its dependents. Each node with depth, repeated, settled (done or dropped, null for a placeholder), its head as parent_head above, blocked_from only for a ticket the caller reads, and — only for a ticket of the caller's own team they read — its assignee and progress (implementation, refinement, review, derived), else null; the walk goes on only from a ticket the caller reads, so a head and a placeholder are leaves; open counts the open ones whose state the caller reads, each once, on every page. limit, cursor (sealed), If-None-Match |
GET …/{number}/prerequisites |
deprecated, replaced by …/prerequisite-tree: the same tree inside the team, among the tickets the caller sees. Each node with its key, title, state, blocked_from, assignee, the three progress stages, depth, settled (done or dropped) and repeated — a ticket the tree holds under two others stands in full under the first and as repeated under each other; open counts the open ones of the whole tree, each once, on every page. A ticket the caller cannot see is absent, and so is what lies only behind it |
GET …/{number}/interest |
who holds a stake in it |
PUT …/{number}/interest |
set the caller's own stake; 201 new, 200 otherwise; the stake carries the agent mark and the token of the write that set it |
DELETE …/{number}/interest |
remove the caller's own stake |
GET …/{number}/markdown |
its canonical Markdown, text/markdown; the ETag is its version; every call is recorded |
GET …/{number}/context |
the ticket for reading, text/markdown: one first line naming the ticket, the time, the person and the agent — or the token, (through the token <name>) —, the canonical Markdown, then ## Links and ## Prerequisites (the tree of …/prerequisite-tree, each prerequisite once) — a ticket of another team by its key, title and state, a placeholder as <team> [Confidential] —, ## Recent comments (the last comments, default 10, up to 100; 0 leaves the section out), ## Attachments and ## Recent activity (the last activity, the same bounds); what the caller cannot see is absent; no ETag; every call is recorded. No import format (grammar) |
GET …/{number}/activity |
every recorded act on it, from the audit record |
The bin of deleted tickets — 3 routes
For the team's administrators, never an agent
(ADR 0024
D1, D2, D7); the purge, the one act on a ticket nothing undoes, in a browser session only. A
token restricted to a project is refused like an unknown team. {key} is a short key,
<PROJECT>-<number>, of a ticket in the bin; any other key — a ticket that is not deleted
included — is 404 not_found.
| Method and path | Does |
|---|---|
GET …/deleted-tickets |
read scope: the deleted tickets the caller can see, the last deleted first — key, project, number, type, title, state, confidential, deleted_at, deleted_by, purge_at (thirty days after the deletion); limit and cursor |
PUT …/deleted-tickets/{key}/restore |
admin scope: bring it back as it was — its links, comments, stakes and place in the rank with it; 200 with the ticket and its ETag, the version raised; recorded as restored |
DELETE …/deleted-tickets/{key} |
a browser session only — a token, an administrator's admin token included, is 403 session_required: purge it now — the ticket, its comments and their revisions, questions, links, stakes, time entries and their revisions, notifications and attachments, then the attachments' objects; its children become roots, and a block that waited on it waits on its key as an external reference; its audit rows stay with their content emptied; 204, recorded as purged. The job ticket-purge does the same thirty days after the deletion |
Saved filters — 5 routes
A saved filter is a named set of the ticket lists' filter parameters
(ADR 0018 D5,
ADR 0049
D6, D7), the owner's, and shared with every member of the team when shared. A token restricted
to a project is refused like an unknown team.
| Method and path | Does |
|---|---|
GET …/filters |
read scope: the caller's filters and those shared with the team, oldest first — id, name, owner, shared, parameters, warnings (a value that no longer holds, checked as it is read), redacted (another member's filter that names a project or a ticket the caller cannot see, or one that is gone: shown without parameters and warnings), version; limit and cursor |
POST …/filters |
any role, write scope: {"name","parameters"} and optionally shared; parameters takes the lists' filter parameters as a JSON object — state, type, severity, security, horizon, effort, assignee, reporter, parent, interest, project as arrays, progress_min, progress_max, the time bounds, q, include_terminal, blocked, has_open_questions — and refuses what the lists refuse, 400 at /parameters/<name>; me stays me, whoever applies it; 201 with ETag and Location; Idempotency-Key |
GET …/filters/{filter} |
one of the caller's or a shared one; ETag |
PATCH …/filters/{filter} |
the owner, write scope: name, parameters (the whole set) and shared; If-Match; a team administrator, admin scope and never an agent: another member's shared filter — one whose owner left the team among them — unshared with {"shared": false} and nothing else, recorded under the administrator's name, the answer a filter they no longer read; anything else of it is 403 forbidden, as is another member's shared filter to everyone else; a filter the caller cannot see is 404 |
DELETE …/filters/{filter} |
the owner, write scope, never an agent (403 agent_forbidden, hard-off: deleting, restoring or purging, before the filter is read); 204; a team administrator, admin scope, deletes another member's shared filter as well; another member's shared filter is 403 forbidden to everyone else, one that is not shared 404 |
Questions and comments — 12 routes
| Method and path | Does |
|---|---|
GET …/{number}/questions |
its questions, by number; every question, wherever the API answers one, carries options_html and answer_html (null without an answer) beside the Markdown, rendered as the body is |
POST …/{number}/questions |
ask one; the person asked must be able to see the ticket — without one the question is open to the team |
GET …/{number}/questions/{question} |
one question |
PATCH …/{number}/questions/{question} |
edit it while open — the asker; If-Match |
PUT …/{number}/questions/{question}/answer |
answer it, or change one's answer — a person decides; an agent with record-answer writes down its person's answer; If-Match once answered |
PUT …/{number}/questions/{question}/withdrawal |
withdraw an open question — the asker |
GET …/{number}/comments |
the comment thread, oldest first (order=desc for newest); every comment carries body_html beside the Markdown, rendered as the body is, null once withdrawn, and mentions, the ids of the persons it mentions, empty once withdrawn |
POST …/{number}/comments |
comment: {"body"} and optionally mentions, the ids of the persons it mentions — each a member who sees the ticket, else 400 at /mentions/<i>; each is told (mentioned) and watches the ticket while the comment stands. The API reads no text — a name typed without the list mentions nobody, and @Name renders as the text it is. The ticket's watchers are told commented; one act tells a person once |
GET …/{number}/comments/{comment} |
one comment |
PATCH …/{number}/comments/{comment} |
edit it — its author, or the person whose agent wrote it; the old text is kept; If-Match; mentions left out keeps the comment's, a list replaces them and tells the persons it adds, each checked as above |
GET …/{number}/comments/{comment}/revisions |
a comment's earlier texts, oldest first; empty once withdrawn |
PUT …/{number}/comments/{comment}/withdrawal |
withdraw it: the text is hidden, the entry stays — its author, their person, or an administrator |
Time — 8 routes
| Method and path | Does |
|---|---|
GET …/{number}/time-entries |
the ticket's time entries the caller may see, and their sum |
POST …/{number}/time-entries |
book the caller's own time — a member, never an agent; a locked day is 409 period_locked |
GET …/{number}/time-entries/{entry} |
one entry |
PATCH …/{number}/time-entries/{entry} |
correct it — its author; the previous values are kept; If-Match |
PUT …/{number}/time-entries/{entry}/void |
void it — its author; kept, and left out of every sum |
GET …/{number}/time-entries/{entry}/revisions |
its previous values |
GET …/time-entries |
the team's entries the caller may see, newest first; filters from, to, project, ticket, person, include_voided; CSV on Accept: text/csv |
GET …/time-report |
minutes summed per ticket, project, person or team (group_by) over a period — team the whole team in one row keyed team; tenant, its deprecated name, as before, keyed tenant —; CSV on Accept: text/csv |
Attachments — 8 routes
| Method and path | Does |
|---|---|
GET …/{number}/attachments |
the ticket's attachments |
POST …/{number}/attachments |
upload a file to the ticket, or to one of its comments: multipart/form-data with file and optionally comment_id; the type is detected from the bytes — PNG, JPEG, GIF, WebP, PDF, UTF-8 text, SVG — anything else is 415; above COWORK_ATTACHMENT_MAX_BYTES 413, a ticket full by COWORK_ATTACHMENT_MAX_PER_TICKET 409 attachment_limit, a file that would take the team above COWORK_ATTACHMENT_TEAM_QUOTA 409 attachment_quota — each before anything is stored; 501 uploads_disabled without object storage |
GET …/{number}/attachments/{attachment} |
its metadata |
GET …/{number}/attachments/{attachment}/content |
its bytes, with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox; raster images inline, everything else as a download; the ETag is the SHA-256 of the bytes; every 200 is recorded |
GET /api/v1/teams/{team}/attachment-usage |
the team's administrators, read scope: {"used_bytes","attachments","quota_bytes"} — every attachment of the team summed, confidential tickets' included and a deleted ticket's until the purge, and COWORK_ATTACHMENT_TEAM_QUOTA or null without one; a weak ETag, 304 to it; anybody else 403 forbidden |
GET …/attachment-consistency |
the team's administrators, read scope: the latest consistency check — check_id and checked_at (null before the first), dangling (files whose bytes are missing, loss not accepted), accepted, orphans (objects under the team's prefix no file names) with orphan_bytes, dangling_attachments (id, file name, size, type, the ticket's key, whether the ticket is in the bin, uploaded, accepted) and orphaned_objects (key, size, last modified), at most 1000 each, and orphan_removal (who, when, removed, kept) or null; beside the check last_exported_at, when a project of the team or the whole team was last exported, by anybody (null while none was); a weak ETag, 304 to it; anybody else 403 forbidden |
POST …/attachment-consistency/orphan-removal |
a team administrator in a browser session only, never an agent: {"check_id"} → 200 {"removed","kept","failed"} — each orphan of that check's list asked again whether a file names it now (kept), the act purged recorded, the objects removed after the commit, a failure logged and listed by the next check; another check than the latest, or one whose orphans were removed already, 409 consistency_check_stale; no orphans, 200 with zeros and nothing recorded; a token 403 session_required; without object storage 501 uploads_disabled |
POST …/attachment-consistency/dangling-acceptance |
a team administrator with admin scope, never an agent: {"check_id"} → 200 {"accepted"} — the check's listed missing files nobody accepted count as accepted from now on, recorded as accepted; nothing is removed, and a file whose bytes come back is whole again; another check than the latest 409 consistency_check_stale |
Import and export — 5 routes
An import is a job on a project in two phases: a dry run that reads every file and imports nothing,
then its execution with the person's corrections, every ticket, question and link of the files it
can import in one transaction, each file with an error or a conflict left out and the report saying
why (ADR 0051,
ADR 0063).
It is a writer's act of the project, as creating a ticket is — the role member, on a restricted
project its list's entry, and write scope —, an agent's too; a job is its maker's and the team's
administrators', and another person's is 404 (ADR 0051 D6). An export follows the reader: it
holds what they may read, streamed as it is read, one export at a time per replica. Steps and the
CronJob of a backup:
docs/operations/import-and-export.md; what passes:
docs/security/import-and-export.md.
| Method and path | Does |
|---|---|
POST …/projects/{project}/imports |
the dry run: multipart/form-data with one or more parts file — a tar.gz or a zip, known by its bytes, or one Markdown file each —, bounded by COWORK_MAX_IMPORT_BYTES and 10 000 files — a zip's entries, its directories included, counted before it is read — (413); a part of another name, a path twice or above 1 024 bytes, or an upload that is no archive is 400 at /file; an archived project 409 project_archived. Reads every ticket file — a repository's NNN-<slug>.md and local_NNN-<slug>.md, an export's <PROJECT>-<n>.md with its manifest.json and links.json — against the project as it stands and keeps the files for the execution; skips everything else, a /context document too, with its reason. A number the project holds, a deleted ticket's included, is a conflict; one a purged ticket held is imported, with a warning. An import through a token, an agent's or not, assigns a confidential ticket to the token's own person or to nobody, the file's line saying why; a browser session's assigns as the file says. 201 with the job and Location: id, project, status dry_run, created_by, created_at, expires_at (twenty-four hours later), executed_by, executed_at, summary — files, create, conflict, error, skip, exclude, created, open, confidential, highest_number — and files[], one per file of the upload in its order: path, outcome (create, conflict, error, skip, exclude, created), reason (why a file is skipped, excluded or left out), format, number, key, conflict, title, type with type_reason, state, block_candidate, block, columns, assignee (source and the member it resolved to), parent, note, questions, links, attachments, confidential with confidential_reason, warnings, errors (each with field, line, message), correction. Recorded as created on the job |
GET …/projects/{project}/imports/{import} |
the job with its report; a writer of the project, an agent too, of a job they made, or a team administrator; a file whose ticket was purged since is out of the report, which the summary still counts. Another person's job, a dry run past its twenty-four hours, or a job of another project, is 404; the job import-expiry deletes the expired ones within the hour |
POST …/projects/{project}/imports/{import}/execution |
execute the dry run: optionally {"corrections": [...]}, each {"path", "exclude", "type", "state", "block": {"kind", "reason", "from"}, "assignee"} — exclude alone; block with the state blocked only, its kind not ticket; assignee a member's id who can see the project, or null —, a broken rule 400 at /corrections/<i>/…. The files are analysed again under the project's rank lock; a file that has an error or a conflict — a number the project holds by now, a deleted ticket's included — is left out, its reason saying why, and every other ticket keeps its number and its source's state, dates, stages, horizon, block, notes and questions, the person who executes is its reporter, the assignee only by identity and only the member the dry run named, the confidential flag by the source's rule, the links within the project made, the sequence advanced past the highest number; 200 with the executed job, status executed, every create now created. Every ticket, question and link has an act naming the job; one act imported on the job, published once as project.changed with the kind imported. A second execution is 409 import_executed; another person's dry run is 404 |
GET …/projects/{project}/export |
the project's tickets the caller may read, done and dropped ones included, as application/gzip — a tar.gz named <team>-<PROJECT>-<YYYYMMDD>.tar.gz holding manifest.json (format cowork export v1, team and tenant beside it, the same slug, projects[] each with key, name, archived, tickets and confidential_not_included, exported_at, exported_by as Name <identity>, the totals tickets and confidential_not_included), links.json (source, type, target of every link whose two ends the caller sees, once), attachments.json (ticket, name, type, size, url — never the bytes) and each ticket's /markdown document at <team>/<PROJECT>-<n>.md; read on the project, an agent too. Recorded as exported on the project |
GET /api/v1/teams/{team}/export |
the same for every project of the team the caller sees, archived ones included, each link once, named <team>-<YYYYMMDD>.tar.gz; read in the team; a token restricted to a project gets 404. Recorded as exported on the team |
Generated by make generate from backend/internal/problem;
every error body carries one of these as code.
code |
Status | Meaning |
|---|---|---|
validation_failed |
400 | The request does not match the API document, or a field breaks a rule; errors[] names each field |
idempotency_key_required |
400 | An agent's POST that creates something came without an Idempotency-Key; a transition needs none (docs/adr/0045 D2, D3) |
invalid_cursor |
400 | The cursor was altered, belongs to another list, or comes from another installation |
page_too_deep |
400 | A numbered page beyond the depth cap; follow the cursor instead |
unauthenticated |
401 | No token or session, a malformed one, or one cowork does not know; a session that expired or was ended answers the same |
token_expired |
401 | The token is past its expiry (docs/adr/0035 D4) |
token_revoked |
401 | The token was revoked, or its person deactivated (docs/adr/0035 D6) |
not_allowed |
401 | The token's person is outside the identity provider's gate: none of their groups, as of their last login or groups refresh, is in COWORK_OIDC_ALLOWED_GROUPS or is COWORK_ADMIN_GROUP, or the person belongs to another issuer than the configured one, or their groups were read longer ago than COWORK_OIDC_GROUPS_MAX_AGE, until a sign-in in the browser reads them again. The token is refused, not revoked, and works again once the person is back inside (docs/adr/0035 D8) |
invalid_credentials |
401 | The local login failed: the same answer, in the same time, for an unknown username, a wrong password, a locked or a deactivated account (docs/adr/0033 D6) |
forbidden |
403 | The person's role does not allow the act (docs/adr/0034) |
insufficient_scope |
403 | The token's scope does not reach the act (docs/adr/0035 D3) |
agent_forbidden |
403 | The act is on the agent hard-off list, needs a capability the token lacks, or lies outside what the capability grants — close closes from in-progress and review only; detail names which (docs/adr/0043 D4, D5) |
session_required |
403 | The route is for a person in a browser session; a personal access token cannot call it (docs/adr/0035 D5) |
password_change_required |
403 | The session's account has a temporary password, which has to be changed before anything else (docs/adr/0033 D4) |
not_initialised |
403 | The installation has no team yet and the person is not a global administrator (docs/adr/0032 D5) |
csrf |
403 | A cookie-authenticated write, or the login, did not come from COWORK_BASE_URL or lacks X-Requested-With: cowork (docs/adr/0037 D1) |
not_found |
404 | No such route, or a team, project or ticket the caller cannot see — the answer does not say which (docs/adr/0047 D5) |
person_not_found |
404 | No active person has this e-mail address or username — nobody logged in with it through the identity provider and no local account has it — or the person named is not a member of the team (docs/adr/0030 D3) |
method_not_allowed |
405 | The path exists with other methods; Allow names them |
username_taken |
409 | The installation has a person with this username; usernames are unique (docs/adr/0033 D2) |
tenant_slug_taken |
409 | The installation has a team with this slug; slugs are never reused. The code keeps the word tenant, the name a team had before (docs/adr/0005 D1, D4) |
project_key_taken |
409 | The team has a project with this key; keys are never reused (docs/adr/0007 D4) |
repository_bound |
409 | Another project of the team binds this repository and path; a repository is in at most one project (docs/adr/0066 D6) |
person_ambiguous |
409 | Several persons have this e-mail address, which is a display attribute and not an identity (docs/adr/0029 D5); nobody was granted |
grant_exists |
409 | The person holds a grant in this team already; change its role with PUT …/members/{person_id}/grant (docs/adr/0030 D3) |
mapping_exists |
409 | The team maps this group already; change that mapping's role instead (docs/adr/0030 D2) |
last_admin |
409 | The change would leave the team without an administrator who can sign in: nobody active and admitted by the gate would hold the admin role, mapped or granted (docs/adr/0034 D1) |
project_archived |
409 | An archived project refuses new tickets (docs/adr/0006 D4) |
state_conflict |
409 | The ticket is not in the state the request assumed, or its state does not allow the change; errors[] names the current state (docs/adr/0045 D2) |
parent_cycle |
409 | The new parent is the ticket itself or one of its descendants (docs/adr/0008 D2) |
link_cycle |
409 | The blocks link would close a cycle of prerequisites (docs/adr/0012 D4) |
open_prerequisites |
409 | Tickets that block this one are not done or dropped; errors[] lists them, and a person may override with a reason (docs/adr/0012 D7) |
import_executed |
409 | The dry run was executed already; a dry run is executed at most once (docs/adr/0051 D3) |
period_locked |
409 | The day lies on or before the team's time_locked_until: the period is closed to new, changed and voided entries (docs/adr/0017 D8) |
attachment_limit |
409 | The ticket holds as many attachments as COWORK_ATTACHMENT_MAX_PER_TICKET allows (docs/adr/0016 D6) |
attachment_quota |
409 | The team's attachments would hold more bytes than COWORK_ATTACHMENT_TEAM_QUOTA allows; nothing was stored (docs/adr/0016 D6) |
consistency_check_stale |
409 | The consistency check the request names is not the team's latest any more — a newer check replaced its lists — or its orphans were removed already; read GET …/attachment-consistency again (docs/adr/0059 D4) |
uploads_disabled |
501 | The installation has no object storage configured; attachments cannot be uploaded, and orphaned objects cannot be removed (docs/adr/0016 D1) |
chat_unavailable |
409 | The team has no chat: the installation configures no provider; GET …/chat says so (docs/adr/0076) |
precondition_failed |
412 | The If-Match version is stale; the response carries the current ETag and errors[] the current values (docs/adr/0050 D5) |
payload_too_large |
413 | The body is larger than the configured limit (docs/adr/0039 D2) |
unsupported_media_type |
415 | The body's type is not one the route accepts |
idempotency_mismatch |
422 | The Idempotency-Key was used before with a different request (docs/adr/0045 D4) |
precondition_required |
428 | An overwriting write came without If-Match (docs/adr/0050 D3) |
too_many_attempts |
429 | More attempts to prove a password — logins and the current password of a change — from this address within a minute than COWORK_LOGIN_ADDRESS_LIMIT allows; Retry-After says how long to wait (docs/adr/0033 D6) |
chat_busy |
429 | The person has as many turns of the chat running as COWORK_CHAT_TURNS_PER_PERSON allows on this replica — in another tab, say; one ends, or is stopped with DELETE …/chat/turns, first (docs/adr/0076) |
internal |
500 | Something failed inside cowork; the request_id finds it in the log |
chat_provider_failed |
502 | The chat's provider could not be reached, refused the request, or answered what cowork cannot read; detail says which, never with the provider's answer. It comes as the error event of a chat turn, whose answer has begun (docs/adr/0076) |
not_ready |
503 | The backend cannot do the work now: it cannot reach its database, it streams no events, or it is shutting down and ends a turn of the chat |
timeout |
504 | The request took longer than the configured limit (docs/adr/0039 D2) |
The backend's instruments, Prometheus text at /metrics on COWORK_METRICS_ADDR
(ADR 0060;
scraping, the alerts and the dashboard: docs/operations/metrics.md).
Source: backend/internal/metrics/metrics.go. Every count is
the replica's but the consistency family's, which the database answers alike on every replica; no
label names a person, a ticket, a key, a token or a request id, and a team only on the consistency
family, by its id — under team, and for this release under tenant beside it, the label's name
before, with the same value, so that a dashboard or a recording rule of your own can move to team.
An alert of your own groups by team only where no alert routing label has that name — the chart's
own example of metrics.prometheusRule.alertLabels routes by team —; otherwise it copies the id to
a label of another name with label_replace, as the chart's alerts will under cowork_team once a
later release drops tenant. The chart's own alerts and dashboard read tenant in this release,
the label every image of an image rollback's window carries. A label set marked at zero exists from the start. A scrape through the Prometheus Operator adds the
target's namespace, pod, container and job.
Every instrument
| Instrument | Type | Labels | Meaning |
|---|---|---|---|
cowork_http_requests_total |
counter | route, method, status |
Requests answered on the API's listener. route is the API document's pattern (/api/v1/teams/{team}/projects/{project}/tickets/{number}) — a request to a deprecated twin under /api/v1/tenants counts under its team path's —, /healthz, /readyz, or unmatched for a path no route matches; method one HTTP defines, else other |
cowork_http_request_duration_seconds |
histogram, the library's default buckets, 5 ms to 10 s | route, method |
From a request's arrival to its handler's return; an event stream and a turn of the chat count their whole life |
cowork_http_requests_in_flight |
gauge | — | Requests being served, the open event streams and turns of the chat among them |
cowork_db_pool_connections |
gauge | state: idle, in_use, constructing |
The pool's connections, read at the scrape |
cowork_db_pool_max_connections |
gauge | — | The pool's maximum: pool_max_conns of COWORK_DATABASE_URL, else the greater of 4 and the CPUs the process sees |
cowork_db_pool_acquire_duration_seconds |
summary: _count, _sum |
— | Successful acquires of a connection and their time; rate(_sum) / rate(_count) is the mean |
cowork_db_pool_wait_duration_seconds |
summary: _count, _sum |
— | The acquires among them that waited because no connection was free, and the time they waited; rate(_sum) is the mean number waiting |
cowork_db_pool_canceled_acquires_total |
counter | — | Acquires a context ended before a connection was free |
cowork_db_query_errors_total |
counter | kind: unique_violation, foreign_key_violation, check_violation, not_null_violation, integrity_violation, serialization_failure, deadlock_detected, insufficient_privilege, lock_not_available, query_canceled, connection, other_sqlstate, canceled, timeout, other |
Failed statements by the SQLSTATE's meaning or what the client saw; a row that was not found is no failure. insufficient_privilege is a policy, a grant or a guard that refused |
cowork_jobs_runs_total |
counter | name |
Runs of a background job on this replica that took its lock, or failed before it could; name is the job's system actor: idempotency-expiry, session-expiry, login-expiry, notification-expiry, import-expiry, ticket-purge, consistency-check (once a day), bootstrap |
cowork_jobs_failures_total |
counter | name |
The runs that failed |
cowork_jobs_duration_seconds |
histogram, 10 ms to 600 s | name |
A run's duration |
cowork_jobs_consecutive_failures |
gauge | name |
Runs that failed one after the other since the job's last success on this replica; CoworkJobFailing fires at 2 |
cowork_events_open_streams |
gauge | — | Event streams the replica's hub holds |
cowork_events_published_total |
counter | — | Notifications the hub received from the database's listener |
cowork_events_subscribers_dropped_total |
counter | reason: behind (its buffer of 256 was full), limit (the person's oldest beyond COWORK_SSE_MAX_STREAMS_PER_PERSON), resync (the listener came back after a loss), at zero |
Streams the hub ended; a shutdown ends none of them here |
cowork_events_replays_total |
counter | outcome: hit, miss, at zero |
Reconnects with a Last-Event-ID: replayed from the ring, or told to resync |
cowork_audit_acts_total |
counter | action (an audit_action), actor: person, agent, system |
Acts committed to the audit record; one of a transaction that rolled back never counts |
cowork_auth_logins_total |
counter | method: local, oidc; outcome: success, failure, locked, throttled, refused, at zero |
Logins; locked and throttled are the local form's, refused a login that proved who it was and was refused all the same — the init state, the identity provider's gate |
cowork_auth_lockouts_total |
counter | — at zero | Usernames locked by failed attempts |
cowork_auth_token_refusals_total |
counter | reason: malformed, unknown, revoked, expired, not_allowed, session_only, at zero |
Personal access tokens refused; session_only is a usable token on a route that takes a session only |
cowork_migrations_schema_version |
gauge | — | The schema version the database records, read at a scrape at most every ten seconds; absent after a read that failed |
cowork_migrations_schema_dirty |
gauge | — | 1 while the recorded version is dirty: a migration failed halfway, or one is running |
cowork_consistency_dangling_attachments |
gauge | team: the team's id; tenant: the same, for this release |
Files of the team whose metadata is there and whose bytes the bucket lacks, and whose loss nobody accepted, at its latest consistency check; read from the stored results at a scrape at most once a minute, the same on every replica; no series for a team without a result |
cowork_consistency_orphaned_objects |
gauge | team, tenant: as above |
Objects under the team's prefix that no file names, at its latest check; after an administrator's confirmed removal, those its list of a thousand did not show |
cowork_consistency_last_export_age_seconds |
gauge | team, tenant: as above |
Seconds since a project of the team or the whole team was last exported, by anybody, as the act exported records it — a ticket's Markdown or context does not count —, or since the team was made where it never was; every team has one. Read from the audit record with the counts, at most once a minute, the age counted at every scrape; CoworkExportOverdue fires above metrics.prometheusRule.exportMaxAgeDays |
go_*, process_* |
The Go runtime's and the process's, client_golang's defaults |
Source: deploy/helm/cowork/values.yaml. Every value below is the default.
nameOverride: ""
fullnameOverride: ""
imagePullSecrets: []
serviceAccount:
create: true
annotations: {}
name: ""
automountServiceAccountToken: false # neither pod talks to the Kubernetes API
database: # the runtime role: owns nothing, held to row-level security
existingSecret: "" # preferred: the credential never enters the release
keys: # the key names of existingSecret (and of existingConfigMap)
url: databaseUrl # COWORK_DATABASE_URL; empty: the components below instead
host: host # COWORK_DATABASE_HOST
port: port # COWORK_DATABASE_PORT; empty: not read, 5432
name: dbname # COWORK_DATABASE_NAME
user: username # COWORK_DATABASE_USER, always from the Secret
password: password # COWORK_DATABASE_PASSWORD, always from the Secret
sslmode: "" # COWORK_DATABASE_SSLMODE; empty: not read, the driver's default prefer
existingSecretKey: "" # the URL's key under its earlier name; set, it wins over keys.url
existingConfigMap: "" # with keys.url empty: host, port, name and sslmode from this ConfigMap
url: "" # renders <fullname>-database; plain text in the release Secret and in `helm get values`
tls:
caConfigMap: "" # ConfigMap with the database's private authority's PEM, mounted at /etc/cowork/database-ca into
# the migration run and the serving container; both roles' sslmode must check it (verify-full)
keys:
ca: ca.crt # the ConfigMap's key; COWORK_DATABASE_CA=/etc/cowork/database-ca/<key>
owner: # the role the migrations run as; only the migration run reads it — the init container or the Job
existingSecret: "" # preferred, and the only source in job mode; required while the release migrates
keys: # as above: COWORK_DATABASE_OWNER_URL or COWORK_DATABASE_OWNER_HOST, _PORT, _NAME, _USER, _PASSWORD, _SSLMODE
url: databaseUrl
host: host
port: port
name: dbname
user: username
password: password
sslmode: ""
existingSecretKey: ""
existingConfigMap: ""
url: "" # renders <fullname>-database-owner while the init container migrates; plain text in the release either way
migrations:
mode: onStart # onStart: the migrate init container of every backend pod; job: the hook Job <fullname>-migrate,
# which then runs the bootstrap too and takes existingSecret references only
session: # COWORK_SESSION_KEY, from a Secret only
existingSecret: "" # required: rendering fails without it
keys:
key: sessionKey
lifetime: 12h # COWORK_SESSION_LIFETIME, the absolute lifetime of a browser session
idle: 2h # COWORK_SESSION_IDLE, how long a session may lie unused
localAdmin: # the local administrator of docs/adr/0032: a global administrator kept in step with this Secret at every start
existingSecret: "" # preferred; with the inline values empty there is no local administrator and nobody can log in
keys:
username: username # COWORK_LOCAL_ADMIN_USERNAME
password: password # COWORK_LOCAL_ADMIN_PASSWORD
username: "" # renders <fullname>-local-admin; plain text in the release and in `helm get values`
password: "" # both inline values or neither; the existing Secret wins; needs backend.config.baseURL
bootstrap:
team: # created at start while no team exists; once one does, these do nothing; needs localAdmin or auth.oidc.adminGroup
slug: "" # COWORK_BOOTSTRAP_TEAM_SLUG; the administrator group, when set, is mapped to its admin role
name: "" # COWORK_BOOTSTRAP_TEAM_NAME; both or neither
tenant: # deprecated: bootstrap.team under its name before, read for this release where bootstrap.team leaves a value empty
slug: ""
name: ""
auth:
local:
passwordMinLength: 12 # COWORK_PASSWORD_MIN_LENGTH, characters; below 8 the backend refuses to start
lockout: window # COWORK_LOGIN_LOCKOUT: window | admin
maxFailures: 5 # COWORK_LOGIN_MAX_FAILURES within fifteen minutes; 0 never locks
addressLimit: 20 # COWORK_LOGIN_ADDRESS_LIMIT attempts per client address (IPv6: per /64) and minute; 0 disables
oidc: # the login through an OpenID Connect provider; every other value is rendered only with the issuer
issuer: "" # COWORK_OIDC_ISSUER, e.g. https://login.example.com; its discovery is fetched at every start, which fails when it cannot be; needs backend.config.baseURL
clientId: "" # COWORK_OIDC_CLIENT_ID; or from existingSecret, with keys.clientId
existingSecret: "" # required with the issuer: the client secret comes from a Secret only, there is no inline value
keys:
clientSecret: clientSecret # COWORK_OIDC_CLIENT_SECRET
clientId: "" # empty: the client id is clientId above
scopes: openid profile email groups offline_access # COWORK_OIDC_SCOPES; offline_access brings the refresh token the groups refresh needs
groupsClaim: groups # COWORK_OIDC_GROUPS_CLAIM
allowedGroups: [] # COWORK_OIDC_ALLOWED_GROUPS, rendered comma-separated: the gate; a name with a comma fails rendering
adminGroup: "" # COWORK_ADMIN_GROUP: global administrators; with allowedGroups empty as well, nobody logs in through the provider
groupsRefresh: 15m # COWORK_OIDC_GROUPS_REFRESH, at least 1m
groupsMaxAge: 168h # COWORK_OIDC_GROUPS_MAX_AGE, longer than groupsRefresh: older groups refuse the person's tokens until they sign in
emailTrusted: false # COWORK_OIDC_EMAIL_TRUSTED: true lets a grant by address match a person without the email_verified claim; only for a provider whose addresses administrators issue
displayName: single sign-on # COWORK_OIDC_DISPLAY_NAME: "Sign in with <name>"
storage: # S3-compatible object storage; without an endpoint uploads are refused
existingSecret: "" # required with an endpoint: the key of a bucket-scoped policy, never root
keys:
accessKeyId: accessKeyId # COWORK_S3_ACCESS_KEY_ID
secretAccessKey: secretAccessKey # COWORK_S3_SECRET_ACCESS_KEY
endpoint: endpoint # the keys of existingConfigMap; a value whose key is empty is the literal value below
bucket: bucket
region: region # may be missing from the ConfigMap: the server's region
pathStyle: pathStyle # may be missing from the ConfigMap: path style
existingConfigMap: "" # the endpoint, bucket, region and path style from a ConfigMap; naming it turns the storage on
endpoint: "" # COWORK_S3_ENDPOINT, e.g. https://s3.example.com; setting it turns the storage on
bucket: "" # COWORK_S3_BUCKET; required with an endpoint
region: "" # COWORK_S3_REGION, set only when non-empty; empty asks the server
pathStyle: true # COWORK_S3_USE_PATH_STYLE
tls:
caConfigMap: "" # ConfigMap with a private authority's PEM, mounted at /etc/cowork/s3-ca
keys:
ca: ca.crt # the ConfigMap's key; COWORK_S3_CA=/etc/cowork/s3-ca/<key>
chat: # the chat in the UI; every value is rendered only with a provider
providers: [] # COWORK_CHAT_PROVIDERS and COWORK_CHAT_<ID>_*: the person picks one, the first is the default.
# Each: {id, name, kind: openai|anthropic, url, model, existingSecret, keys: {apiKey: apiKey}};
# a key from that provider's own Secret only, an inline apiKey fails rendering.
# Every provider receives what the chat reads in every team, confidential tickets included
turnTimeout: 5m # COWORK_CHAT_TURN_TIMEOUT; 0 disables
maxSteps: 8 # COWORK_CHAT_MAX_STEPS; 0 disables
ingress: # per host: /api/ and /auth/ (Prefix) to the backend Service, / (Prefix) to the frontend Service
enabled: false
className: ""
annotations: {} # the controller's settings: body limit and read timeout above the backend's, text/event-stream unbuffered — docs/operations/installation.md#expose-it
hosts:
- host: cowork.example.com # example; the paths are the chart's, a host has none of its own
tls: []
metrics: # Prometheus text at /metrics on the backend pods, no authentication, on no Service (docs/operations/metrics.md)
enabled: true # COWORK_METRICS_ADDR=":<port>" and the container port metrics; false renders it empty, which closes the port
port: 8081 # must differ from backend.containerPort
podMonitor: # needs the Prometheus Operator's CRDs
enabled: false # scrapes the backend's metrics port, and the nginx exporter's with frontend.metrics.exporter.enabled
labels: {} # what your Prometheus selects monitors by, e.g. release: kube-prometheus-stack
interval: 30s
scrapeTimeout: 10s
serviceMonitor: # for a Prometheus that discovers Services only; needs the CRDs
enabled: false # adds the headless Service <fullname>-backend-metrics; the backend alone, not the nginx exporter
labels: {}
interval: 30s
scrapeTimeout: 10s
prometheusRule: # the alerts of docs/operations/metrics.md; needs the CRDs
enabled: false
labels: {} # what your Prometheus's ruleSelector matches
alertLabels: {} # on every alert, for Alertmanager's routing; a severity here replaces the alert's own
restoreWindow: 24h # how long a team's consistency counts stay above zero before CoworkAttachmentsOutOfStep fires
exportMaxAgeDays: 7 # whole days a team may go without an export of it or a project before CoworkExportOverdue fires: your export schedule's interval with room for a failed run
grafanaDashboard: # a ConfigMap with the dashboard, for the Grafana sidecar
enabled: false
labels:
grafana_dashboard: "1" # kube-prometheus-stack's sidecar label
annotations: {} # e.g. the folder your sidecar reads
backend:
replicaCount: 1 # in onStart mode every pod migrates in its init container; they serialise on an advisory lock
image: # the migrate init container and the migration Job run the same image
repository: guidedtraffic/cowork-backend
pullPolicy: IfNotPresent
tag: "" # default: the chart appVersion
containerPort: 8080 # COWORK_LISTEN_ADDR=":8080"
service:
type: ClusterIP
port: 8080
config:
migrateOnStart: true # read in onStart mode: the migrate init container; false: nothing migrates — run `cowork migrate` yourself
logLevel: info # COWORK_LOG_LEVEL, also for the init container and the Job
logFormat: json # COWORK_LOG_FORMAT, also for the init container and the Job
shutdownTimeout: 15s # COWORK_SHUTDOWN_TIMEOUT; keep below terminationGracePeriodSeconds
baseURL: "" # COWORK_BASE_URL, set only when non-empty: the origin the browser shows; required with localAdmin or auth.oidc.issuer
trustedProxies: "" # COWORK_TRUSTED_PROXIES, set only when non-empty: the Ingress controller's networks, comma-separated CIDRs
maxJsonBody: 1048576 # COWORK_MAX_JSON_BODY, bytes; 0 disables
attachmentMaxBytes: 10485760 # COWORK_ATTACHMENT_MAX_BYTES, bytes; 0 disables (the notes then ask the controller for no body limit either)
attachmentMaxPerTicket: 100 # COWORK_ATTACHMENT_MAX_PER_TICKET; 0 disables
attachmentTeamQuota: 0 # COWORK_ATTACHMENT_TEAM_QUOTA, bytes a team's attachments hold together; 0, the default, sets none — set it with several teams
attachmentTenantQuota: 0 # deprecated: attachmentTeamQuota under its name before, read for this release while attachmentTeamQuota is 0
maxImportBytes: 52428800 # COWORK_MAX_IMPORT_BYTES, bytes of an import's upload and of its files unpacked; 0 disables (the notes then ask the controller for no body limit either)
requestTimeout: 30 # COWORK_REQUEST_TIMEOUT, seconds; 0 disables; the event stream is exempt
maxPageSize: 200 # COWORK_MAX_PAGE_SIZE; 0 disables
maxQueryLength: 256 # COWORK_MAX_QUERY_LENGTH, characters; 0 disables
sseReplayWindow: 5m # COWORK_SSE_REPLAY_WINDOW
sseMaxStreamsPerPerson: 10 # COWORK_SSE_MAX_STREAMS_PER_PERSON, per replica; 0 disables
extraEnv: [] # appended verbatim to the backend container's env
podAnnotations: {}
podLabels: {}
podSecurityContext: # distroless nonroot user
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
securityContext: # the binary writes nothing to disk; the init container and the Job have the same
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources: # the init container and the Job have the same
limits:
memory: 256Mi # also the backend container's GOMEMLIMIT (the downward API): the Go runtime's soft limit, the collector working harder before the kernel kills (docs/operations/runtime.md)
requests:
cpu: 50m
memory: 128Mi
probes:
startup: # /healthz; the migration ran before, in the init container or the Job
periodSeconds: 5
failureThreshold: 36
liveness: # /healthz
periodSeconds: 10
readiness: # /readyz, a database ping
periodSeconds: 10
terminationGracePeriodSeconds: 30
nodeSelector: {}
tolerations: []
affinity: {}
frontend:
replicaCount: 1
image:
repository: guidedtraffic/cowork-frontend
pullPolicy: IfNotPresent
tag: "" # default: the chart appVersion
containerPort: 8080 # fixed by the nginx configuration in the image
service:
type: ClusterIP
port: 80
extraEnv: [] # the chart sets none; the image's entrypoint switches, e.g. NGINX_ENTRYPOINT_QUIET_LOGS
podAnnotations: {}
podLabels: {}
podSecurityContext: # nginx-unprivileged user
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
securityContext: # the emptyDir at /tmp is all nginx writes; the configuration is in the image
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
limits:
memory: 64Mi
requests:
cpu: 10m
memory: 32Mi
probes:
liveness: # /healthz, answered by nginx
periodSeconds: 10
readiness:
periodSeconds: 10
terminationGracePeriodSeconds: 30
nodeSelector: {}
tolerations: []
affinity: {}
metrics:
exporter: # the sidecar nginx-exporter: reads stub_status on 127.0.0.1:8082 in the pod, scraped by metrics.podMonitor
enabled: false
image:
repository: nginx/nginx-prometheus-exporter
tag: "1.5.3"
pullPolicy: IfNotPresent
port: 9113 # the container port nginx-metrics, no authentication
resources:
limits:
memory: 32Mi
requests:
cpu: 5m
memory: 16MiRendering fails, naming the value, without database.existingSecret or database.url;
without database.owner.existingSecret or database.owner.url while the release migrates —
backend.config.migrateOnStart true in onStart mode, or migrations.mode: job; in job mode
with database.url, database.owner.url or the inline local administrator instead of their
existingSecret; with a migrations.mode other than onStart and job; with a database role's
keys.url empty and no key for its host, name, user or password, or with its
existingConfigMap beside a URL; without session.existingSecret; with a
storage.endpoint or a storage.existingConfigMap but no bucket or no
storage.existingSecret; with one of
localAdmin.username and localAdmin.password alone, or a local administrator without
backend.config.baseURL; with an auth.oidc.issuer but no backend.config.baseURL, no client
id (auth.oidc.clientId, or auth.oidc.keys.clientId with the Secret) or no
auth.oidc.existingSecret, or with a group in auth.oidc.allowedGroups that holds a comma; and
with bootstrap.team.slug and .name apart, or without a local administrator or an
auth.oidc.adminGroup of a configured issuer; with bootstrap.team and bootstrap.tenant, or
backend.config.attachmentTeamQuota and attachmentTenantQuota, set to different values — a quota
of 0 is unset —; with metrics.port equal to
backend.containerPort; and with a ServiceMonitor or a PrometheusRule while metrics.enabled is
false, or a PodMonitor that would scrape nothing. helm lint reports
these as info lines, helm template and helm install fail. Where a Secret reference and its
inline URL are both set, the reference wins and the URL is ignored.
bootstrap.tenant.* and backend.config.attachmentTenantQuota, the names before of
bootstrap.team.* and backend.config.attachmentTeamQuota, are still read for this release where
the new value is empty or 0 — and so for a release upgraded with --reuse-values from a chart
without the new values. The chart renders the three variables under both names, the new and the
one before, with the one value, so that an image rolled back to the release before, which reads only
the names before, keeps them (deprecated variable names); the chart's
notes name each of the values before that is set, with its replacement. A later release reads and
renders the new names only.
The modes that change what is exposed:
- Secret references or inline URLs. With
database.existingSecretanddatabase.owner.existingSecretno credential enters the release.database.urlanddatabase.owner.urlare for throw-away installations: each is stored in plain text in the Helm release Secret and returned byhelm get values, and the chart's notes warn for each. The owner URL is the worse one to inline — its role can switch row-level security off. - The owner credential reaches the migration run only — the
migrateinit container, or in job mode the migration Job. The serving container gets the runtime role's connection andCOWORK_MIGRATE_ON_START=false; never add the owner URL tobackend.extraEnv. WithmigrateOnStart: falseinonStartmode the chart needs no owner credential and renders no owner Secret — an inlinedatabase.owner.urlstill stays in the release values, readable withhelm get values, so leave it empty — and nothing migrates: the backend refuses to start untilcowork migratehas run. migrations.mode: jobruns the migrations and then the bootstrap in the Helm hook Job<fullname>-migratebefore every install and upgrade; a failed Job fails the release before a new pod starts. The Job runs before the release's own Secrets exist, so it takesexistingSecretreferences only. Besides the two roles' connections it holds the local administrator's Secret and, with anauth.oidc.adminGroup, the issuer and that group — never the identity provider's client secret, the server key or the storage key (job mode).- A database role as components. With its
keys.urlempty, a role's Secret is read key by key — the user and the password always from the Secret, the host, port, name and sslmode fromexistingConfigMapwhen one is named — and the backend composes the URL; a named key a Secret or ConfigMap lacks stops the pod rather than falling back. database.tls.caConfigMapletssslmodebeverify-fullagainst a private authority, such as CloudNativePG's: the backend trusts that authority alone for both roles' connections and the migration run, and refuses to start beside ansslmodethat checks nothing. Without it a server that is not under a public authority can only be reached withrequire— encrypted, the server not verified (the database). Whoever may write theexistingConfigMapordatabase.tls.caConfigMapis trusted like whoever may read the Secret beside it (trust boundaries).- The server key has no inline path. One Secret gives every replica the same key; rotating it invalidates the cursors clients hold and ends each session of the identity provider that holds a refresh token at its next refresh.
- The identity provider's client secret comes from a Secret only.
auth.oidc.existingSecretnames it — there is no inline value — and the client id is a value or the same Secret'sauth.oidc.keys.clientId. Every otherauth.oidcvalue is rendered only whileauth.oidc.issueris set, so emptying the issuer alone switches the provider off; the notes name the redirect URI to register,<backend.config.baseURL>/auth/callback, and warn while the gate names no group. The members ofauth.oidc.adminGroupare global administrators: whoever may change that group at the provider administers the installation (operations). - The local administrator: a Secret or inline values.
localAdmin.existingSecretnames a Secret whose keyslocalAdmin.keys.usernameand.passwordhold the account; the chart never sees the password.localAdmin.usernameand.passwordrender<fullname>-local-adminfor a throw-away installation: plain text in the release Secret and inhelm get values, likedatabase.url, with a warning in the notes. The account follows the Secret at every start — a changed value reaches the pods when they restart, which everyhelm upgradedoes while an inline credential is set (the pod annotationcowork/inline-credentials-revision); rotate the Secret and restart to end a leaked password with the sessions and tokens made with it, then review the grants it made (operations). backend.config.trustedProxiesand network policies. Empty, the login throttle counts the Ingress controller pod as the one client of every browser behind it; set to the controller's networks it counts the browser. Too wide a list lets a client choose its address, and a network that holds other pods lets every one of them that reaches the backend choose its own. The chart ships no NetworkPolicy —networkPolicy.*is gone, and network policies are the cluster administrator's —, so keeping other pods away from the backend is a policy of the cluster's. The chart's notes warn about the empty list and about the list set (operations).ingress.annotationsare the controller's limits. The Ingress routes/api/and/auth/to the backend, so the controller's body limit and read timeout must sit above the backend's or it answers the413and504itself, as its own page; the chart does not know the controller and prints the figures in its notes (expose it).- The storage key comes from a Secret only; endpoint, bucket, region and path style are plain
values or, with
storage.existingConfigMap, a ConfigMap's. Withoutstorage.endpointand without the ConfigMap the backend runs without object storage and refuses uploads. Whoever may write that ConfigMap orstorage.tls.caConfigMapis trusted like whoever may read the storage key's Secret. metrics.enabledopens a port without authentication. On by default: the backend pods answer/metricsonmetrics.portto every pod that reaches them — counts of the installation's activity, never a person, a ticket or a token, and a team only by its id on the consistency check's counts (H-63). The port is on no Service and the Ingress never routes it; the chart ships no NetworkPolicy, so admitting only the monitoring namespace is a policy of the cluster's (metrics.md).falsecloses it.frontend.metrics.exporter.enabledopens the exporter's port on the frontend pods the same way.0inbackend.configswitches a backend limit off, and the chart's notes then ask the Ingress controller for no limit either —maxJsonBody: 0for no body limit,requestTimeout: 0for an hour's read timeout. No production values file should carry one (runtime.md, limits).attachmentMaxBytes: 0removes the upload maximum; one upload at a time is then read whole (attachments.md H-12).
make help # every target, grouped
make dev # the whole stack with demo data; the UI on :4200 with live reload
make generate # after a change to backend/api/, the SQL queries, the problem catalogue or the dashboard; CI fails on drift
make frontend-generate # after make generate changed the API document: the Angular client; CI fails on drift
make lint cyclo gosec vuln
make dev-up postgres-tls-up # what the integration tier needs: PostgreSQL, one that serves TLS, Silo and Dex (dex-up and dex-down alone)
make test test-integration
make frontend-lint frontend-test-coverage frontend-build
make helm-lint helm-template examples-lint # the chart with every ci/ values file; deploy/examples/ against CloudNativePG's CRD schema and Silo's chart
make docker-build e2e # the end-to-end suite in Chromium and WebKit against both images (make e2e-browsers once)
make build # bin/cowork and frontend/dist/frontend/browser
make build-mcp # bin/cowork-mcp; GOOS= GOARCH= cross-compile
make docker-build # both images, from backend/Containerfile and frontend/Containerfiledocs/developer/ has the layout, the package map, the architecture, the build and test matrix, the checklists, the toolchain versions and the CI and release process.
The UI uses PrimeNG, PrimeIcons and @primeuix/themes, which since version 22 are under the
PrimeUI License: free under its Community License with a key, which the released images carry.
A build without a key works and shows PrimeNG's license notice; a fork needs a key of its own
(ADR 0052
D9).