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

GitLab Integration

Crewlet integrates with GitLab as a first-class code host, alongside — and independent of — the GitHub integration. The two coexist; an org can enable either or both. The split is the same one GitHub uses: GitLab tools are for reading, reviewing, and tracking code (diffs, comments, reviews, approvals, MR/issue state, pipelines); authoring code changes goes through the code sandbox.

What GitLab adds over GitHub is automated, per-agent identity provisioning. Each agent seat gets its own GitLab service account — a first-class user that can be assigned issues and merge requests, @-mentioned, and requested as a reviewer, but which costs no billable seat and cannot sign in through the UI. Because GitLab exposes an API to create these accounts and mint their tokens, crewlet gitlab provision reconciles the whole company config into GitLab in one command — something GitHub’s API cannot do (see GitHub vs GitLab identity). The top-level integrations.gitlab block carries the inbound-webhook and identity-resolution config.

Prerequisites. The operator creates the top-level GitLab group by hand and mints the operator credential — a group-Owner PAT with the api scope on GitLab.com, or an instance-admin PAT on self-managed (see the permission matrix); provisioning automates everything below that. The same configuration covers gitlab.com and self-hosted instances alike — point integrations.gitlab.url at your instance.


Connect GitLab in Settings › Integrations with the instance address, the group and a group Owner token. The engine generates the signing secret and the reconcile loop creates the service accounts on its next tick, running the same pass crewlet gitlab provision runs.

The group Owner token is sealed in the fleet’s secret store and kept. That token creates accounts and mints tokens on them, which is a standing power, and it is held anyway for one reason: removing an account needs the authority that created it, so with nothing kept there was no way to disconnect the integration and take the accounts away with it.

Two things the dashboard deliberately does not do, both of which stay on the command line: rotating every seat’s token, which revokes the credential every agent is currently authenticating with, and decommissioning accounts whose seats have left the configuration, which cannot be told apart from a company mid-edit.

See Running the provisioning pass.

The signing secret is generated, and its shape is not a formality. GitLab computes the HMAC over the decoded bytes of a whsec_ value and accepts no other form, so a secret of any other shape cannot match a delivery it will then refuse. Nothing downstream catches one: the value goes to the secret store and the document gets a ${VAR}, which is the one thing config validation cannot check the shape of, because the reference is all that layer ever sees. Both the dashboard and crewlet gitlab provision mint the same shape, and GitLab’s own Generate button produces it too.

The top-level integrations.gitlab block is non-tool config — it enables inbound webhook handling and boot-time identity registration:

integrations:
gitlab:
enabled: true
url: "https://gitlab.com" # instance base URL — REQUIRED
signing_secret: "${GITLAB_SIGNING_SECRET}" # whsec_<base64 of 32 bytes>, the hook's signing token, REQUIRED
token: "${GITLAB_ROUTING_TOKEN}" # optional read credential → participants-based routing
webhook_name: crewlet # which hooks on the instance are this deployment's
provisioning: # read by the engine's reconcile loop AND by `crewlet gitlab provision`
group: nimbus-hq # top-level group the agent service accounts join
access_level: developer # default group membership (developer | maintainer)
access_levels: # per-handle overrides
tech-lead: maintainer
username_prefix: "" # e.g. "agent-" when the group namespace is shared with humans
projects: [] # extra projects to add each account to (+ hooks only when group_webhook: false / falls back)
group_webhook: auto # auto (group hook, else per-project) | true (group only) | false (per-project only)
mode: group # where accounts are OWNED: group (default) | instance (self-managed only)
token_scopes: [api] # scopes minted on each service-account PAT

access_levels is keyed by seat handle, and a key no seat holds is not refused: the engine logs it on every node, once per applied epoch, as an org_dangling_reference warning with ref=gitlab_access_level (see Dangling references). Remove the key when you remove the seat. Left in place, it grants its level to the next seat whose handle matches, which is typically the same role added again.

Four fields differ from the hosted code host’s block beside it:

  • url is required when GitLab is enabled — the instance address is needed for webhook links, boot-time identity resolution (GET {url}/api/v4/user), and provisioning. GitHub’s is optional, because github.com serves its API from a different host and needs no address at all.
  • signing_secret is required when enabled — it is the HMAC key every inbound delivery is verified against, and the route’s only credential (see Verification). It must be whsec_ followed by standard base64 over a 32-byte key, the only shape GitLab signs with; a value that is not one is refused at validation, and one arriving through an unresolved ${VAR} stops the code host at boot rather than 503-ing every delivery in silence. A GitLab older than 19.1 cannot sign at all and therefore cannot deliver to this engine. Point it at a ${VAR} and you don’t even have to invent a value: when crewlet gitlab provision -public-url … runs and that var is unset, the provisioner generates a whsec_… secret, stamps it on the hook, and writes it back to the token sink — see Provisioning. See Webhooks.
  • webhook_name says which hooks on the instance are this deployment’s. The reconcile converges the hooks carrying that name whatever address they currently point at, which is what stops a change of public base leaving live orphans behind — one group hook and one per project per change, all enabled, all signed, all delivering somewhere that no longer answers. Measured on a deployment behind a restarted tunnel: a group hook and two project hooks, invisible to every pass. A hook that shares the name but points at anything other than a /webhooks/gitlab path was not registered by this engine and is left alone, and so is a hook with no name: this engine names every hook it registers, and a GitLab older than 17.1, which drops the name, is refused — the hook it created is removed rather than left for every later pass to create again. It defaults to crewlet, and two deployments watching one instance must set two names: staging and production of one company share this document, so with a single name each pass would repoint the other’s hooks and only the last to run would receive anything. Disconnecting removes the hooks by the same name, so the two halves cannot disagree about which hooks are yours.
  • token (optional) enables participants-based routing: comments and state changes fan out to everyone participating in the issue/MR — GitLab’s own notification reach — instead of only assignees and mentioned users. Webhook payloads don’t carry the participants list, so this costs one GET …/participants REST call per comment/state-change event, made with this credential (any group member’s PAT with read_api, which you supply — nothing provisions it). Without it, routing degrades to payload-derived targets — directed events are unaffected. This mirrors integrations.jira’s admin token, which exists for the same reason (watcher lookups). See Event routing.

The provisioning: sub-block drives the reconcile described under Provisioning, and both the CLI and the engine’s own reconcile loop read it.

mode says where a service account is owned, and it is not only the CLI’s business. It decides which endpoint creates an account, which one mints its tokens, and which one deletes it — and the group delete answers 404 as success (“unknown or already removed; both are the state the caller asked for”), so an instance-owned account sent down the group route reports itself deleted and stays live with every credential it holds. It used to be -mode on the command line and nowhere else, so the engine assumed group for every company: against accounts created with -mode instance its passes minted through a group that does not own them and its disconnect removed none of them. crewlet gitlab provision -mode … still overrides it for one invocation, the way -public-url overrides integrations.public_base_url.


Declare the GitLab MCP tool server once in mcp_servers as a shared: false server. Each agent supplies its own service-account PAT in role.mcp_env.gitlab; a sandbox-enabled role also declares the same PAT in role.sandbox.env for the git-auth recipe:

mcp_servers:
- name: gitlab # official glab CLI MCP server (stdio)
shared: false
command: glab
args: ["mcp", "serve"]
roles:
- name: Agent SWE
mcp_env:
gitlab:
GITLAB_TOKEN: "${GITLAB_TOKEN_SWE}" # per-agent service-account PAT (glab reads it)
GITLAB_HOST: gitlab.com
sandbox:
enabled: true
env:
GITLAB_TOKEN: "${GITLAB_TOKEN_SWE}" # same PAT for the git-auth recipe

Provisioning mints into the ${VAR} referenced by the block’s credential key — GITLAB_TOKEN, GITLAB_PERSONAL_ACCESS_TOKEN, Private-Token, or Authorization: Bearer …, the same keys boot-time identity resolution reads. Everything else in the block (GITLAB_HOST, an API url) is config and is left alone, so two seats may point at one shared ${GITLAB_HOST} without it being mistaken for a credential they both claim.

The default is the official glab CLI stdio MCP server (glab mcp serve): the engine’s MCP bridge spawns one glab process per role with that role’s mcp_env.gitlab env, so the per-agent PAT (GITLAB_TOKEN) IS the per-agent identity — no separate server to run. Boot-time identity resolution reads the token from whichever key is present (GITLAB_TOKEN, GITLAB_PERSONAL_ACCESS_TOKEN, a Private-Token header, or Authorization: Bearer <pat>), so the http alternative below works too. The engine names no tool-specific variable — the whole mcp_env.gitlab block is forwarded verbatim to the role’s MCP instance — and GITLAB_TOKEN is declared in role.sandbox.env by the founder exactly as GITHUB_TOKEN is (see Code Sandbox). A role with no mcp_env.gitlab gets no GitLab tools.


The default tool server is the official glab CLI running its built-in MCP server, glab mcp serve (stdio). It’s declared like any stdio server and the engine’s MCP bridge spawns one glab process per role with that role’s mcp_env.gitlab env — the same shape as the atlassian (uvx mcp-atlassian) server — so there is no separate MCP server to run or host. Each process authenticates as its own service account via GITLAB_TOKEN (and GITLAB_HOST for self-managed). The served surface is the full glab command tree exposed as tools — MR approve (glab_mr_approve), diff (glab_mr_diff), notes and diff discussions (glab_mr_note, glab_mr_note_create), full issue CRUD, CI/pipelines, and glab_api for any raw authenticated call (including glab_api /user for identity) — and interactive commands are excluded, with --output json added automatically.

  • Requirement: the glab binary must be on the engine host (it’s spawned there). Install per GitLab’s CLI docs; Homebrew (brew install glab) is the officially supported cross-platform method.
  • Status: glab mcp serve is flagged experimental by GitLab (“may be unstable or removed”). It’s official and actively developed; pin a known-good glab version if that risk matters for your deployment.

Alternative — one shared server: @zereight/mcp-gitlab (community, MIT; docker image zereight050/gitlab-mcp) in streamable-HTTP + remote-authorization mode (STREAMABLE_HTTP=true, REMOTE_AUTHORIZATION=true) is a single process for the whole fleet, each request authenticated by its own Private-Token header. Declare it as a shared: false http server (url: http://…/mcp) and put the PAT in mcp_env.gitlab as Private-Token. Choose this if you’d rather run one hosted server than a glab binary on the engine host; guardrails (GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS, GITLAB_DENIED_TOOLS_REGEX) trim the catalogue, and GITLAB_API_URL targets self-managed.

GitLab’s built-in server-side MCP endpoint (POST /api/v4/mcp, Free tier since 19.2) is not used. Its authentication is OAuth-only — dynamic client registration with interactive consent per identity, unworkable for headless per-agent identities (PAT auth is an open request, gitlab-org/gitlab#586184). When PAT auth lands, adopting it is a single-server swap.

As with every MCP surface, the engine hardcodes no tool names — a bundled mcp:gitlab Tool Skill frames the tools as read/review/track with authoring pointed at the sandbox, mirroring the mcp:github skill.


Agents that the founder has gated with role.sandbox.enabled author code through the code sandbox, not through any GitLab tool. The executor has a run_sandbox tool: a coding agent (Claude Code / OpenCode) runs inside an isolated E2B sandbox and opens a merge request as the agent’s own GitLab identity (the PAT the role declares as GITLAB_TOKEN in role.sandbox.env — by convention the same PAT as its mcp_env.gitlab header). The call is detached — the executor’s loop suspends and resumes with the result when the run completes, so the agent reports the MR in the same turn. The full design is in Code Sandbox.

The GitLab git-auth recipe is config you write, not engine code — the engine ships no git-auth, the same stance as GitHub. Without it a headless git clone https://gitlab.com/... dies with could not read Username: git has no way to supply the token on its own. The GitLab form of the recipe on the sandbox page:

providers:
sandbox:
setup:
- name: git-auth
files:
/usr/local/bin/git-credential-crewlet: |
#!/bin/sh
# Supply this seat's GitLab token for gitlab.com credential
# requests ONLY. Reads $GITLAB_TOKEN from the environment
# (never persisted to disk).
[ "$1" = "get" ] || exit 0
[ -n "$GITLAB_TOKEN" ] || exit 0
ok=""
while IFS= read -r line; do
[ -z "$line" ] && break
[ "$line" = "host=gitlab.com" ] && ok=1
done
[ -n "$ok" ] || exit 0
echo "username=oauth2"
echo "password=$GITLAB_TOKEN"
commands:
- chmod +x /usr/local/bin/git-credential-crewlet
- 'git config --global credential."https://gitlab.com".helper /usr/local/bin/git-credential-crewlet'
- 'git config --global --add url."https://gitlab.com/".insteadOf "[email protected]:"'
- 'git config --global --add url."https://gitlab.com/".insteadOf "ssh://[email protected]/"'
- 'git config --global user.name "$CREWLET_AGENT_HANDLE"'
- 'git config --global user.email "$CREWLET_AGENT_EMAIL"'
env:
GIT_TERMINAL_PROMPT: "0"
brief: >-
Use $GITLAB_TOKEN (already in your environment — this seat's own
GitLab PAT) for all GitLab work: git authenticates to gitlab.com
with it automatically, so just clone, fetch, and push over plain
HTTPS. SSH-style remotes (`[email protected]:...`) are rewritten to
HTTPS for you. Never embed the token in a URL. To open a merge
request, use GitLab push options — no CLI needed:
`git push -o merge_request.create -o merge_request.target=main
-o merge_request.title="<title>" origin HEAD:<your-branch>`
creates the MR under your own identity. Your git commit identity
is preconfigured.

Every security property of the GitHub form holds here and for the same reasons — the helper is scoped to the host at both layers (the credential."https://…".helper key and the host= check in the script), it stays silent with no token so public clones fall through to anonymous, the insteadOf rewrites use --add because the key is multi-valued, and the commit identity comes from the engine’s generic $CREWLET_AGENT_* facts. Two GitLab-specific wrinkles: the basic-auth username is arbitrary for PAT auth (GitLab ignores it — the token is the password, so oauth2 is a conventional placeholder), and the helper must match the host including a non-standard port when the instance runs on one (the dev compose serves gitlab.local:8929), so template the host from your integrations.gitlab.url rather than copying gitlab.com blindly.

Once an MR exists, GitLab tools stay in the picture on the read/review/track side: agents read its diff, comment, approve, and follow the MR’s webhooks to report back to the original requester. As with GitHub, capture context via reflect_and_persist(ttl_days=30) whenever you kick off async work (a sandbox coding job) so the original ask + repo + MR number surface in your ## Personal memory block on the review-notification turn.


crewlet gitlab provision <company.yaml> is a one-shot, idempotent reconcile from company config to GitLab state, runnable any number of times.

Terminal window
GITLAB_ADMIN_TOKEN="$GITLAB_ADMIN_TOKEN" crewlet gitlab provision company.yaml \
-public-url https://engine.example.com \
-env-file .env.gitlab
FlagDescription
company.yaml (positional)Path to the Tier B company YAML
-admin-tokenOperator credential (see the permission matrix below). Empty reads $GITLAB_ADMIN_TOKEN
-public-url URLThe engine’s public base address, e.g. https://engine.example.com — not a webhook path. The engine owns its six webhook routes and derives /webhooks/gitlab itself, so there is no path to mistype. Omit to skip webhook registration
-secret-storeWrite minted credentials into the encrypted secret store instead of an env file — the engine reads them back directly, so there is nothing to source, and against a running node every peer reads them too. Needs a Tier A keyring (-config)
-env-file PATHEnv file to append/update minted tokens into. Ignored with -secret-store
-printPrint export VAR=token lines to stdout and persist nothing
-config PATHTier A config naming this node’s store and secret keyring (default crewlet.yaml). Only -secret-store reads it
-rotateMint a fresh token for every seat, including seats whose current one still works. Not the default, and not what a re-run does: GitLab returns a token’s value once, so minting every run would revoke the credential every agent is currently authenticating with — an operator adding a tenth seat would take the other nine down. Restart the engine after
-decommissionDelete managed service accounts whose seats have left the config. Off by default: it is the one destructive direction, and a company mid-edit looks exactly like a company that removed a seat
-mode group|instanceWhere service accounts are owned, for this run only. It defaults to integrations.gitlab.provisioning.mode, which is where the answer lives: the engine provisions the same company from the same document and reads no flags, so a mode that existed only on the command line had the CLI creating accounts one way and the loop minting and deleting them the other. group creates them under provisioning.group; instance creates them on the instance itself. Self-managed only — GitLab.com does not serve the instance route, and a run that asks for it there is refused naming this flag. An unknown value is refused before the config is even loaded
-token-expiry-days NLifetime minted onto each token. 0 sends no expiry and lets the instance policy decide
-dry-runPrint what the run would do and touch nothing

The CLI probes the operator credential with GET /user up front and fails fast with the failing endpoint and status if the token or its scopes are wrong. It then runs two preflights so common setup gaps surface as one clear message instead of a stack of API errors:

  • Service-accounts access. A single list call to the service-accounts API. A 403 here — even with a valid group-Owner api token — is GitLab.com’s identity-verification gate, not a scope problem; the run aborts with Provisioning cannot proceed: … pointing at identity verification.
  • Declared projects exist. Each provisioning.projects entry is checked with GET /projects/:id. A project that does not exist (404) is dropped and named in a report note rather than aborting the whole reconcile on the first missing one — create it (or remove it from the config) and re-run. The accounts, tokens, and any projects that do exist still reconcile.

For each agent seat that declares GitLab credentials (presence of mcp_env.gitlab, the same convention GitHub uses):

  1. Ensure the service account exists. Look it up by username — <username_prefix><handle> — and create it if missing, under the configured group or (with -mode instance) on the instance. The display name is role.name. No email address is sent, so GitLab assigns one itself (service_account_group_…@noreply.<instance>).

    That omission is load-bearing. GitLab’s service-account routes take email as optional, and its documentation adds the sentence this turns on: “custom email addresses require confirmation before the account is active”, unless the group has a matching verified domain. Crewlet used to derive <prefix><handle>@noreply.crewlet.invalid — a reserved TLD (RFC 2606), so no confirmation mail can ever be delivered and no domain can ever be verified. Every account created that way was permanently inactive, and GitLab refused each of its tokens with 403 Your primary email address is not confirmed. Letting GitLab name the address keeps the same promise — the account is a robot and its mailbox does not exist — on a domain the instance actually controls.

    An account GitLab will not let sign in stops the seat here. GitLab’s service-account delete blocks rather than erases, so the ordinary disconnect-then-reconnect finds an account that still exists, still answers the lookup, and holds no credential that can ever work. The pass used to walk straight past that: mint a token, seal it, have the very next request refused, and mint another on the next tick, for ever. It now mints nothing into an account whose state is anything but active — blocked, deactivated, ldap_blocked or banned — and reports the seat as identity_failed naming the account and the state GitLab itself reports. An absent state is not read as blocked: older listings and the creation response omit the field, and reading absence as “cannot sign in” would report every seat on such an instance as failed while provisioning nothing.

    It is reported rather than fixed, and that is GitLab’s doing. Unblocking is POST /users/:id/unblock, an instance-admin route, while the ordinary deployment provisions with a group Owner token — so attempting it would be a 403 on every tick. This is the one place the shared rule for apps that disable rather than delete cannot be carried out, and no provenance marker is written either: a marker is only worth having where the engine could act on it. With mode: instance the finding links to /admin/users?filter=blocked, because there the operator credential is an instance administrator; in group mode it gives the state in words and no link, since where a group Owner administers service accounts has moved between GitLab versions and a link that 404s costs a trip to find out.

    The lookup is mode-independent, GET /users?username=, which sees every account on the instance whatever owns it. That is what makes switching modes safe: an operator who moves a company from group to instance finds the accounts it already has instead of colliding with their own usernames. Nothing migrates an existing account between owners — GitLab has no such operation — so a company that wants its accounts genuinely instance-owned decommissions and re-provisions them.

  2. Ensure membership. Read the group’s roster, and each listed project’s, and write only on a real difference: a seat that is not a member is added at its access level (access_level, with access_levels per-handle overrides); one already there at that level costs no request; one at a level the config no longer asks for is updated.

    That last case used to do nothing. The pass only ever POSTed, and GitLab answers a POST for an existing member with 409, which was swallowed as success — so editing access_level or an access_levels override changed nothing at all for a seat that was already a member, silently, and the only way to apply it was to remove the account and re-provision. A converged pass also sent one POST per seat and one per seat × project on every tick for ever; at ten seats and four projects that was about fifty writes every ten minutes that changed nothing.

    Access level and merging. A Developer can push a branch and open an MR, but GitLab’s default protected branch (main) only permits Maintainers to merge — so for an autonomous review→merge loop (no human doing the final merge), provision the code-active seats as maintainer. The trade-off: membership here is group-wide and uniform, so group-Maintainer means an agent can merge any project in the group; scope that behaviourally with an “own your repos” policy. Hard per-repo scoping (Maintainer only on owned projects, Developer elsewhere) would need per-(seat, project) access levels, which the reconcile does not model today — provision the group at developer and add per-project maintainer memberships out of band if you need it. Alternatively, keep developer and relax each project’s protected-branch “Allowed to merge” to include Developers (a project setting the provisioner does not manage).

  3. Ensure a token. The provisioner derives the env-var name from the config itself — it scans the seat’s mcp_env.gitlab values and its sandbox.env.GITLAB_TOKEN for unresolved ${VAR} references (so a sandbox-authoring seat with no MCP surface still gets its token; other sandbox env keys are never scanned). For each referenced var with no recorded value, it mints a PAT (scopes from provisioning.token_scopes, default [api]; expiry from -token-expiry-days) named crewlet-<handle> and writes VAR=glpat-… to the sink. So the config’s ${GITLAB_TOKEN_SWE} reference is the contract and the provisioner fills it — it never invents its own naming scheme. This is what makes minting idempotent: GitLab never returns a token value after creation, so a seat whose ${VAR} already carries a value is skipped.

    A minted token is proved before it is sealed. It is checked against GET /user immediately: a token refused seconds after GitLab issued it means the account cannot authenticate, not that the credential is stale. The pass then revokes every token it owns on that account, seals nothing, and reports the seat as identity_failed — for a person to fix at GitLab. Without that check the refusal is indistinguishable from a stale token, so the pass mints a replacement, which is refused for the same reason, on every tick, for ever. Measured against GitLab.com: 144 live api-scoped tokens on one service account, valid for a year, none of which had ever worked. A seat in this state is deliberately left with no credential; the alternative is that pile.

  4. Nothing is provisioned for integrations.gitlab.token. That field holds the read-only credential participants-based routing uses, and you supply it: a personal access token with the read_api scope, set on the GitLab card or written into the ${VAR} the field references.

    This step used to claim otherwise, saying a dedicated crewlet-engine service account was provisioned with Reporter access and a read_api PAT minted into that var. No such account was ever created and integrations.gitlab.token is read by no reconcile path in this package — so the engine warned gitlab_has_no_routing_token, the setup form declared no input for it, and there was nowhere at all to supply the value the warning was about. The form asks for it now, as an optional field, and the warning names it.

  5. Ensure webhooks (only when -public-url is passed). Register the events the router acts on — issues_events, merge_requests_events, note_events, pipeline_events — and every other event explicitly off, pointing at the engine’s /webhooks/gitlab, carrying the signing_secret as the hook’s signing_token (the caller-supplied, write-only whsec_… value GitLab uses to sign the webhook-signature header; it is never returned, so it must come from your side — see Verification). Existing hooks carrying this deployment’s webhook_name are updated, not duplicated, whatever address they point at — and updated only when what they carry differs from what the company asks for: a missing signing token, a routed event switched off, an unrouted one switched on, TLS verification disabled, or a signing key that is no longer the one this deployment holds. A hook that already matches is left alone rather than re-PUT on every pass.

    The hook’s description records which key it carries, as a digest. GitLab never returns a hook’s signing token and answers exactly one question about it — whether one is set — so “does this hook hold the key the fleet currently holds” had no answer, and a secret rotated any other way (crewlet secrets set, the signing-secret field on the setup form, a peer’s apply) never reached the hook: the pass found it converged, wrote nothing, and GitLab went on signing with the previous key while the engine verified with the new one and refused every delivery, on a surface reporting ready. The description is the only field this engine controls that GitLab gives back, so the answer goes there as crewlet:<12 hex characters> — a truncated SHA-256 over a domain string and the secret. It is one-way and 48 bits wide: enough to notice a change, useless for recovering anything, since the secret is 32 random bytes. Edit or clear it and the next pass re-keys the hook once, which is the correct direction — a hook this engine cannot place is one it should re-key.

    Auto-generated signing secret. If signing_secret points at a ${VAR} that is unset, the provisioner generates a valid whsec_<base64-of-32-bytes> secret, stamps it on the hook, and records it to the token sink (env file or -print) under that var name — the same mint-into-${VAR} contract used for seat tokens. Source the sink into the engine’s env and both sides share the value; re-runs reuse the persisted secret rather than regenerating (so a rotated hook and the engine stay in sync). A value this deployment has already sealed is read back and reused even when the variable resolves to nothing, which matters to the reconcile loop rather than to this command: a ${VAR} resolves from a snapshot taken at apply time, so in the window between a pass sealing a secret and something rebuilding that snapshot the resolver answers empty for a key the fleet already holds — and without the read-back the loop minted again on every tick, re-pointing the hook at a key the running engine does not hold. This only happens when a hook is actually being created (-public-url given); provide the var yourself to pin a specific secret.

    Off is stated, never omitted. GitLab defaults push_events to true, so a subscription body that simply does not mention push subscribes to it — every push on every repository delivered to an engine that answers 200 and drops it. So the hook is registered with the full flag set: the four above true and the other fifteen false. An emoji award is the near miss and is deliberately off: it names a user and a target but no party to notify.

    See Where the webhook lands for which level it goes on.

Human seats are never created — they carry contact.gitlab_username and are resolved, not provisioned.

Same ${VAR} in both places. Point role.sandbox.env.GITLAB_TOKEN at the same ${GITLAB_TOKEN_<SEAT>} reference as mcp_env.gitlab.GITLAB_TOKEN (as the examples do) — one PAT, one identity for both tools and git.

One level, never both. A group hook already fires for every issues/merge_requests/note/pipeline event in every project of the group and its subgroups, and GitLab is explicit that a group hook and a project hook subscribed to the same events both fire for an in-project event — double delivery, which the engine’s completion ledger deduplicates and its inbox does not. So provisioning.group_webhook picks a level:

ModeBehaviour
auto (default)Try one group hook; on success stop, because it covers every project including ones added later. If the instance does not serve the group hooks API, fall back to one hook per provisioning.projects entry and record a note saying so
trueGroup hook only. Fail if the group hooks API is unavailable — no silent fallback, because the mode exists for an operator who needs the group-level guarantee and would otherwise find out the day a new repository went unwatched
falsePer-project hooks only, one per listed projects entry, without touching the group

The group hooks API is not everywhere, and on gitlab.com Free it is worse than absent. A free group accepts the registration: POST /groups/:id/hooks answers 201, the hook appears in the group’s settings, and its own event log stays empty for ever. Measured on a live free group, where the pass reported ready and not one delivery had ever arrived. So auto reads the group’s tier rather than waiting for a refusal, and it reads it from both places that state it. GET /groups/:path omits plan for a free group exactly as a self-managed instance omits it, so asking only there made the one deployment this protects indistinguishable from the one it must not touch: the group read as paid, the fallback never ran, and the pass registered a group hook that GitLab accepted and never delivered. Measured on a live free group — /groups/<path> answered with no plan at all while /namespaces/<path> answered plan: "free" for the same path in the same second, and neither a comment on an issue nor a new issue fired anything.

The namespace is asked only when the group says nothing, so a self-managed instance answers unknown at both endpoints and keeps the group hook it serves. A tier the instance could not answer for is not free: the pass stops rather than concluding one, because moving a working group hook to per-project hooks over a blip — and back on the next pass — is somebody’s webhooks rewritten on a timer.

The refusal path still exists beside it, because the API is also absent from Community Edition, and GitLab hides an unavailable endpoint as a 404 rather than answering 402 — so “not found” is what an instance says about a feature its tier does not serve. auto treats a 403/404 from that endpoint as the tier gate. Any other refusal — a 401, a 5xx, a transport failure — is a real problem and aborts, because falling back on it would paper over a broken credential with a set of project hooks nobody asked for.

Measured, because the obvious guess is wrong: the unlicensed gitlab-ee image this repository’s docker compose --profile gitlab stack runs (19.3.0, no license) does serve GET /groups/:id/hooks, so the local loop takes the group path. Set group_webhook: false to exercise the per-project path there.

Per-project mode needs provisioning.projects to list something. A run with none refuses rather than registering nothing: an instance reporting a healthy integration that delivers to nobody is exactly the failure the skip-rather-than-guess rule exists to prevent.

The report names the level — on the group or on N project(s) — because the two are not interchangeable, and the difference only shows up the day somebody adds a repository.

A disconnect sweeps every project in the group, not only the ones the config names. The config is not a record of where the hooks are: a run that established per-project hooks wrote them on the projects named at the time, so a project dropped from provisioning.projects since — or a company that connected with the group alone and never named one — left them unreachable, and a teardown that visited only the current list reported success having walked past them. Measured on a live disconnect: two hooks still on a project in the group, pointing at dead trycloudflare tunnels from earlier runs, which nothing would ever visit again. That matters because such a hostname is re-issued to whoever asks for it next: the deliveries cannot be forged into the engine (they are signed with a secret the stranger does not have), and the payloads still leave your GitLab. So the teardown enumerates the group’s projects — subgroups and archived projects included — and removes this engine’s hooks from all of them, unioned with whatever provisioning.projects names, since a project there need not be in the group at all. Only hooks carrying your webhook_name at a /webhooks/gitlab path are touched; a nameless hook there is somebody else’s and is left in place.

The steady-state reconcile deliberately does not walk the group like that. It runs every few minutes and is already O(seats × projects) over the projects you named; a group with a thousand projects would turn every tick into a thousand hook listings to converge hooks on the handful the config asks for. A disconnect happens once, when somebody presses the button, and is the one moment the whole group is worth reading. Above 2000 projects it refuses and names the hooks to remove by hand rather than grinding through them.

The level a pass stops writing at is swept, in both directions. The level moves on its own — group_webhook is a live field and the auto answer depends on the group’s plan — and a hook left at the level nobody writes any more goes on delivering every event the new level already delivers. So a run that establishes a group hook removes this engine’s hooks from each declared provisioning.projects entry, and a run that registers per-project hooks removes this engine’s group hook. Only hooks carrying your webhook_name at a /webhooks/gitlab path are touched; anything else on the group or the project is left where it is. A project dropped from provisioning.projects keeps its hook, for the same reason a departed seat keeps its account: a company mid-edit looks exactly like one that removed a project.

Three sinks, chosen by flag:

  • -secret-store: write each minted value into the encrypted secret store under the same ${VAR} name the config references. The engine consults the store ahead of the environment, so the source + restart step disappears entirely — and a run against a node whose engine is up records the credential where the whole fleet reads it. This is the recommended sink once a Tier A keyring is configured.
  • -env-file PATH: append/update VAR=token lines — the file the operator feeds the engine. Written through on every mint, so a crash mid-run cannot leave a minted-but-unrecorded credential, and each write is atomic and leaves the file 0600 — including when you created it yourself, which under the usual umask means 0644. A newly minted token is shown once; re-runs never re-print a live token.
  • -print: emit export VAR=token lines to stdout for shell eval and persist nothing.
  • -rotate mints a fresh token for every seat — including seats whose current one still works — retires the previous crewlet-provision:<handle> tokens on that account, and updates the chosen sink. It is a flag rather than what a run does because GitLab returns a token’s value exactly once: a provisioner cannot check that what it recorded last time still matches, so minting every run would revoke the credential every agent is currently authenticating with. An operator adding a tenth seat would take the other nine down, from a command whose whole promise is that it is safe to re-run. The engine has to be restarted after. On the GitLab.com Free tier — where every PAT expires within 365 days — this is the once-a-year cron candidate.
  • -decommission (explicit, never default) deletes managed service accounts whose seats have left the config. It refuses to act unless provisioning.username_prefix is set, so it can identify managed accounts without touching un-prefixed ones. Off by default because it is the one destructive direction, and a company mid-edit looks exactly like a company that removed a seat.

A run that changed nothing still says so: the report names the seats it kept, because a report listing only changes reads as a run that did nothing — and the operator’s next move would be to reach for -rotate, which is exactly the outage above.

Permission matrix — the operator credential

Section titled “Permission matrix — the operator credential”

The provisioner’s own credential is an admin credential. On the command line it is passed by -admin-token or $GITLAB_ADMIN_TOKEN and read from the environment only. From the dashboard it is integrations.gitlab.provisioning.admin_token: a ${VAR} in the document whose value is sealed in the fleet secret store, like every other credential.

It is the one credential here nothing rotates, so the engine warns before it lapses. Every seat’s token is minted and replaced by the pass; this one was pasted in by a person, and the day it expires every pass is refused and no agent can be provisioned or repaired. Each pass reads the token’s own expires_at (GET /personal_access_tokens/self, GitLab 15.5 and later) and, inside 14 days of it, reports a credential_expiring finding naming integrations.gitlab.provisioning.admin_token and the date. The integration stays ready — the token works today — and the tile reads Credential expiring with Rotate token as its one action until a new token is stored. A GitLab that cannot say (an older instance, or a credential that is not an access token) leaves a note on the run and no finding.

It is held rather than asked for each time, and the reason is the disconnect. Removing a service account needs the authority that created it, so a credential dropped after every pass left no way to take an account away: every one this engine created outlived the integration. Disconnecting names the secret in orphaned_secrets, so revoking it afterwards is one command.

TargetRequired credential
GitLab.com (primary)A top-level group Owner PAT with the api scope — no instance admin. Everything the provisioner touches (service accounts, their PATs, memberships, hooks) is group-Owner-callable on GitLab.com
Self-managed, -mode group (default)An instance admin PAT, or a group Owner PAT with the instance setting allow_top_level_group_owners_to_create_service_accounts enabled
Self-managed, -mode instanceAn instance admin PAT, always — a group Owner cannot create an account the instance owns. A 403 on this route says so by name, because the same status means a different remedy in each mode and “403 Forbidden” alone tells an operator nothing about which

Every token operation goes through the group that owns the account, which is what makes the GitLab.com row above true:

OperationGroup route (a group Owner may call)Instance route (admin only)
MintPOST /groups/:id/service_accounts/:uid/personal_access_tokensPOST /users/:uid/personal_access_tokens
ListGET /groups/:id/service_accounts/:uid/personal_access_tokensGET /personal_access_tokens?user_id=
RevokeDELETE …/personal_access_tokens/:token_id under the groupDELETE /personal_access_tokens/:token_id

Both list routes are paged to exhaustion, and that is not a performance note. Each is read to make a destructive decision — retiring the tokens this tool minted earlier, and emptying an account being decommissioned — so a truncated read leaves a live api-scoped credential behind while the run reports that it cleaned up. Unpaged, the listing returned GitLab’s default twenty rows; on an account that had accumulated 164 tokens those twenty were the oldest and every one was already revoked, so each pass retired nothing, minted another, and called it a successful rotation.

On GitLab.com nobody is an instance admin, so a run reaching for the right column is refused, and the three refusals arrive at three different moments: a mint 403s outright, a list 401s on the next pass after minting has already succeeded, and a revoke fails inside a rollback whose message says a live credential was left behind. Instance mode uses the right column, because there the credential is an admin token and no group owns the account.

On the GitLab.com Free tier, annual token rotation is the norm — every new PAT expires within 365 days (non-expiring service-account tokens require the Premium group setting). Wire crewlet gitlab provision -rotate into a yearly cron.

Two GitLab.com-specific conditions must hold before the service-accounts API will answer, or provisioning aborts on the first preflight with a 403:

  1. The group Owner’s identity is verified. GitLab.com blocks the service-accounts API until the top-level group Owner (the account whose PAT you pass as the operator credential) has completed identity verification — adding a credit card and/or phone number (no charge). This is an anti-abuse gate, unrelated to token scope: a brand-new automation account with a valid group-Owner api PAT still gets a 403 until it verifies. This is the most common cause of a 403 on an otherwise-correct setup.
  2. provisioning.group is a top-level group. Service accounts are owned by, and managed from, the top-level group (they can then be invited into descendant subgroups and projects). Pointing provisioning.group at a personal namespace or a subgroup path yields a 403. Free tier allows up to 100 service accounts per top-level group.

Both surface as Provisioning cannot proceed: GitLab denied the service-accounts API for group '…' (403) … with the fix inline. Service accounts are generally available on the Free tier (GitLab ≥ 18.11).

Declared projects must already exist. The provisioner reconciles seats, tokens, memberships, and webhooks onto projects listed in provisioning.projects — it does not create the projects themselves. Create each project in the group first (or leave projects empty and rely on the group hook + group membership). A listed project that doesn’t exist is dropped with a note, not created.


Inbound GitLab events arrive at POST /webhooks/gitlab.

A delivery is authenticated by its signature, and by nothing else.

webhook-signature is verified as a Standard-Webhooks HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed on the whsec_… secret’s base64 payload, compared in constant time against any of the header’s space-separated v1,… entries, with a ±5-minute timestamp tolerance in both directions.

  • No signature, or a wrong one → 401. There is no fallback. GitLab’s other secret — the plaintext X-Gitlab-Token — is not a credential here.
  • A delivery before signing_secret is configured, or one whose value is not a usable key → 503 with a Retry-After, so the delivery is held for retry rather than discarded. The request was fine; what is missing is on this side.

GitLab signs when the hook has a signing token, not when the version is new enough. These are two different secrets on the same hook and they are not variants of one idea:

FieldWhat GitLab does with it
signing_tokenSigns every delivery, and sends webhook-id, webhook-timestamp, webhook-signature. Never returned by the API.
tokenEchoes it back verbatim as X-Gitlab-Token. GitLab’s own docs call this “not recommended” and “weaker”.

Given the signing key in token, the instance does exactly as asked — it never signs, and it echoes a 32-byte HMAC key back in cleartext on every delivery. Signing is supported from 19.1 (19.0 behind the webhook_signing_token flag).

A GitLab older than 19.1 therefore cannot deliver to this engine at all. That is what “mandatory” means; the alternative is accepting a plaintext bearer token on a public endpoint.

crewlet gitlab provision sets the hook’s signing_token, mints a secret when the ${VAR} is unset, and reads the hook back to confirm the instance kept its name and reports signing_token_present — so a too-old GitLab fails the run instead of delivering unsigned for ever.

If the key may have leaked, re-run with -rotate, which replaces the signing secret as well as the seat tokens.

GitLab does not auto-retry failed webhook deliveries (and auto-disables a hook after 4 consecutive failures), so operators use the manual resend endpoint; the engine carries the delivery UUID / Idempotency-Key into event metadata so resends are idempotent.

The parser turns a payload into a list of per-recipient notifications (one comment can @-mention several agents; one update can add several assignees/reviewers). Each names exactly one GitLab username, which the inbound service resolves to an agent or human seat, and carries project, mr_iid/issue_iid, url, actor_external_id (who caused the event) and an event_type of "{object_kind}.{action}". The MR or issue is the conversation — nimbus/api!42, nimbus/api#42 — project-qualified because an iid is unique only within its project, and that reference is the same string the prompt prints, the coalescer partitions on and the conversation ledger files under — GitLab’s two keys coincide, because an item is one object that is both the merge unit and the durable thread.

Routing mirrors GitLab’s own notification semantics, in two layers:

  • Directed events target exactly the named party from the payload — an assignment, a review request, a mention, a failed pipeline. These never depend on any extra lookup.
  • Thread activity — comments and state changes — fans out to the issue/MR participants (author + assignees + reviewers + commenters + previously-mentioned users): exactly the set GitLab itself would notify. Participants are not in webhook payloads, so this layer needs the integrations.gitlab.token read credential (one GET …/participants call per event); without it — or when the lookup fails — routing degrades to the payload-derived assignees.
Hook (object_kind)Routed toevent_type
issueOn update: newly-added assignees (diff of changes.assignees) + newly-added description @mentions (diff of changes.description). On open/reopen: assignees + description mentions. On close: participants (a human closing an agent’s issue must reach the agent), assignees as fallbackissue.assigned, issue.mention, issue.close
merge_requestOn update: newly-added reviewers (changes.reviewers), assignees (changes.assignees), and description mentions. On approval/approved/unapproval/unapproved/merge/close: participants, assignees as fallback. On open: reviewers + assignees + description mentions. On reopen: same, plus a participants fan-out (the whole thread wakes)merge_request.review_requested, merge_request.assigned, merge_request.mention, merge_request.{approval,approved,unapproval,unapproved,merge,close,reopen}
note (comment)Every @-mentioned registered username (directed), then participants (thread activity), noteable assignees as fallbacknote.mention, note.comment
pipelineOnly when object_attributes.status == failed: the actor who triggered it — the owner who needs to fix the build, and the one event in the whole engine allowed past the self-action rulepipeline.failed
emojiParsed but not routed (no reliable target on award events)—

Each recipient gets one notification per event — the first, highest-signal reason wins, so a mentioned participant pings as a mention rather than as thread activity.

The event’s actor is not filtered by the parser. It is stamped under the one metadata key every integration writes, and the self-action rule suppresses it centrally — which is what lets the rule resolve an actor across identity namespaces (an agent’s bot id and its member id are one seat) and lets pipeline.failed state its exception once, in the prompt, instead of as a flag each parser has to remember to set.

Mentions stay explicitly extracted rather than inferred from participation, for two reasons: a mention is a directed ask and gets a tailored prompt (participation can’t distinguish “this note pings you” from “you once commented”), and GitLab materialises new mentions into the participants list via a background job, so the lookup can race the webhook — text extraction can’t. GitLab sends raw markdown with no parsed mention array, so the parser extracts word-boundary @username tokens from note bodies and issue/MR descriptions (on update, only mentions added by the edit count — re-saving a description doesn’t re-notify, matching GitLab’s own semantics).

Both mention and participant fan-out are intersected with the registered GitLab usernames (agents ∪ human seats) — only parties the engine can route to are targeted, so outsiders (or @here, [email protected]) never produce undeliverable notifications. Bursts on the same MR/issue collapse into one digest turn via inbox coalescing, exactly like GitHub PR events — the participants fan-out raises reach, and coalescing keeps the turn cost bounded.

The prompt dispatches on the routing reason, not the event type, because one merge-request event reaches a reviewer, an assignee and a watcher and asks each of them for something different:

event_typePrompt behaviour
merge_request.review_requestedRead the diff, not the description; approve or comment on the diff; tell the requester where the conversation started. Declining is a reply, not silence — a review request is a direct ask
merge_request.assigned / issue.assignedRead the item in full, do the work (an issue’s code changes go through the sandbox, which opens an MR under the agent’s own identity), report back
note.mention, issue.mention, merge_request.mentionEvaluate whether you were actually asked to do something, then respond on the same thread
issue.closeStop. An agent that keeps working a closed issue is spending budget on a deliverable nobody will take; say what was already done, and raise a disagreement on the issue rather than reopening it
pipeline.failedRead the job log — the status says nothing about the cause. The prompt states plainly that the agent is being told about its own action deliberately, or a seat that has learned “I am not notified of what I did” reads its own name as a routing mistake
Everything else (approvals, non-mention comments, merges, participant thread activity, and any reason a later release adds)You are informed because you take part in this thread — which is a reason to be informed, not a request to act

Review requests, assignments, and failed pipelines are pointer events: they name a diff, a thread or a job log to fetch before the agent has real context, so the turn-start relevance prefetches skip their aux-LLM call rather than filtering against a pointer — the executor searches for itself once it has the context. A comment is not one — its body is what was said.


A GitLab webhook names people by username, and nothing in the org model says which account a seat holds. Without that mapping every event names a stranger, the routing gate drops every target, and the integration is silently inert — so this is the whole integration, not a detail of it.

At engine startup the engine calls GET {integrations.gitlab.url}/api/v4/user with each seat’s own credential from mcp_env.gitlab and registers the returned (gitlab, username) → agent handle mapping. The username is derived from the credential, never declared beside it: a declaration that disagrees with the token is a misroute nothing can detect, and it would make the engine name a variable the seat’s actual tools do not read. This is REST rather than an MCP round-trip because the official MCP server has no whoami and community servers disagree on its name, whereas GET /user is stable core API and needs only the seat’s own token.

The lookups run concurrently and are cached by token. Identity is a function of the credential and credentials change rarely, so a config revision that touched something else re-registers every seat from the cache with no requests at all; a rotated token is a cache miss and costs exactly one, which is correct — it may well be a different account. Boot on a company of thirty seats is therefore one round trip per distinct credential, in parallel, not thirty in series.

A seat whose lookup fails is left unresolved rather than failing the boot — the instance may be briefly down — and the next apply retries it. What that costs is that seat’s inbound routing until then, reported as gitlab_seat_identity_unresolved. A seat whose ${VAR} credential does not resolve is skipped (no MCP instance was started for it either), and if two seats resolve to the same username the second is refused with a warning rather than misrouting.

The credential is read from whichever key the seat’s tool stack names it under — GITLAB_TOKEN, GITLAB_PERSONAL_ACCESS_TOKEN, Private-Token, or Authorization: Bearer … — so the engine still names no tool-specific variable of its own; it reads the one the tools already use.

Human seats register their contact.gitlab_username through the same contact reconciliation every backend’s human identities use, so a founder’s or teammate’s GitLab activity is attributed by name in agent prompts and webhook sender resolution — with no extra plumbing. A human’s identities are declared rather than resolved against the instance, so that pass needs no credential and no network.


The two integrations rhyme deliberately, but they differ in exactly one place — how a per-agent identity comes to exist:

  • GitHub has no API to create assignable user identities. An identity that can be @-mentioned, assigned an issue, and requested as a reviewer must be a real user account, and github.com offers no API to create user accounts or mint their tokens (2FA is mandatory). Machine users are therefore hand-created, and every seat rides a hand-minted PAT. Fully automated provisioning exists only on GitHub Enterprise.
  • GitLab provides service accounts created via API — Free tier, no billable seat, full user semantics (assignable, mentionable, reviewer-able), custom username/display-name/email, and API-managed tokens. That is why crewlet gitlab provision can go from “role in company.yaml” to “agent with working credentials” with zero UI clicks, and GitHub cannot.

A profile-gated GitLab lives in docker-compose.yml so the whole loop is testable locally without touching a real gitlab.com group. It stays out of a plain docker compose up (GitLab is heavy) and opts in with a profile:

Terminal window
docker compose --profile gitlab up -d
scripts/gitlab-dev-bootstrap.sh # mint a root token, open the SSRF allowlist, seed a group, provision

The profile ships one service:

  • gitlab — gitlab/gitlab-ee:19.3.1-ee.0 served at http://gitlab.local:8929. The EE image is deliberate: service accounts are a Free-tier feature that lives in EE-edition code, so the FOSS gitlab-ce image 404s on the /service_accounts API — an unlicensed gitlab-ee image runs as Free tier and serves it.

There is no MCP-server sidecar: the GitLab tool surface is glab mcp serve, which the engine spawns per-role (see MCP tool server).

examples/nimbus.company.yaml is the Nimbus example org, and it targets gitlab.com as shipped: an integrations.gitlab block, a gitlab MCP server per engineering role, and a sandbox those seats push from. The walkthrough below re-points a copy at this local instance, which is also most of what you would do to put a real company on a self-hosted GitLab. Its seven seats, their handles and the ownership split between them (nimbuscore/nimbusk0s, console/website, the Phase-2 framework) are already written for this. (Its smaller sibling, examples/nimbus-claude-cli.company.yaml, has no code host at all — its engineering seats run code on the engine host with nowhere to push it, and the blocks below are exactly what it is missing.)

  1. Resolve gitlab.local. The instance’s external_url is http://gitlab.local:8929, so the engine/CLI must resolve that name to the published port on localhost. Add to /etc/hosts:

    127.0.0.1 gitlab.local
  2. Bring up GitLab and seed it. The gitlab profile brings up GitLab alone — the engine needs no other service:

    Terminal window
    docker compose --profile gitlab up -d # first boot of GitLab takes 3–6 min
    scripts/gitlab-dev-bootstrap.sh # waits, mints a root PAT, opens the webhook SSRF allowlist, seeds nimbus-hq/nimbuscore

    The script prints the root PAT (glpat-crewlet-dev-bootstrap) and the UI login (root / $GITLAB_ROOT_PASSWORD). Local unlicensed gitlab-ee runs as Free tier but with no identity-verification gate, so the service-accounts API works immediately — none of the gitlab.com identity-verification friction applies locally.

    curl http://localhost:8929/-/readiness returns 404 from the host — that’s expected, not a failure. GitLab’s monitoring endpoints (/-/readiness, /-/liveness, /-/health, /-/metrics) are IP-restricted to 127.0.0.0/8/::1/128 by default, and a host-side curl to the published port arrives with the Docker gateway’s source IP, so GitLab hides them with a 404. docker ps showing the container (healthy) is the real signal (its healthcheck runs the same curl inside the container, where localhost is allowlisted). To check from the host, exec into the container (docker exec <gitlab> curl -sf http://localhost:8929/-/readiness) or hit a non-restricted route like /users/sign_in. The REST API (/api/v4/…) is not restricted, so provisioning works from the host regardless.

  3. Make a GitLab-wired copy of the config. Start from the example and add the four blocks this page documents, with every host reference pointed at gitlab.local:8929 (the default repo .gitignore covers *.local.company.yaml, so a copy you later personalize with real contact IDs can’t be committed by accident):

    Terminal window
    cp examples/nimbus.company.yaml nimbus.local.company.yaml

    Then edit nimbus.local.company.yaml to add:

    • integrations.gitlab — the block under Configuration, with url: http://gitlab.local:8929 and provisioning.group: nimbus-hq. access_level: maintainer, so an agent can merge its own reviewed MR: GitLab’s default protected main lets only maintainers merge, and a Developer would stall the review→merge loop on a human.
    • the gitlab MCP server — the shared: false glab mcp serve entry under Per-role wiring.
    • mcp_env.gitlab on each of Agent SWE, Agent Frontend SWE and Agent AI Systems Engineer — a per-seat ${GITLAB_TOKEN_*} placeholder plus GITLAB_HOST: http://gitlab.local:8929. Give Agent DevRel one too if you want it filing issues.
    • the git-auth recipe under providers.sandbox.setup — the example already configures the sandbox and gates the three engineering seats on it, so what is missing is only the ability to push: add the recipe above with its host set to gitlab.local:8929, and each seat’s own PAT in role.sandbox.env. Under run_in: direct root the helper in the box’s home rather than a system path, per the note under Local sandboxes. See the caveat at the end of this section: a cloud sandbox cannot reach a laptop.

    Every ${VAR} you write as a placeholder is what the provisioner mints in the next step; do not paste a literal token.

  4. Provision the agents. The root PAT is the operator credential; -public-url is the engine’s base address — its embedded API, on whatever api.port your Tier A file binds (8000 in examples/nimbus.config.yaml), reachable from the GitLab container via host.docker.internal — and the provisioner appends /webhooks/gitlab itself. No GITLAB_SIGNING_SECRET needed — the provisioner generates one and writes it to the env file:

    Terminal window
    GITLAB_ADMIN_TOKEN=glpat-crewlet-dev-bootstrap \
    crewlet gitlab provision nimbus.local.company.yaml \
    -public-url http://host.docker.internal:8000 \
    -env-file .env.gitlab

    Only nimbus-hq/nimbuscore is seeded by the bootstrap, so the config’s other projects (nimbusk0s, console, website) are checked, dropped with a note, and everything else still reconciles — create them in the UI if you want them, then re-run (the reconcile is idempotent). The compose instance serves group hooks, so the webhook lands on the group; see Where the webhook lands for the instances where it does not.

  5. Run the engine with the minted tokens sourced. examples/nimbus.config.yaml works as the Tier A file as-is: with a api.port above 0 the engine’s embedded API both receives the GitLab webhooks and serves the dashboard, so one process is the whole stack. The port only has to match the -public-url you provisioned with. (Do not also start a second, ingress-only node here — the two would fight over the port; splitting ingress off is for fleets only):

    Terminal window
    source .env.gitlab
    crewlet run -config crewlet.yaml -company nimbus.local.company.yaml
  6. Drive the loop in the UI (http://gitlab.local:8929): create an issue in nimbus-hq/nimbuscore and assign it to an agent’s service account (their handle appears in the assignee list). The assignment webhook wakes that agent, which reads the issue via its own glab MCP tools and acts as itself.

The full loop this validates: provision (service accounts appear with the agents’ handles) → assign/mention → webhook wakes the agent → it reads/comments as itself → (where the sandbox can reach the instance) opens an MR as itself → the reviewer-added webhook wakes the reviewer → the review lands under the reviewer’s identity.

Sandbox code-authoring is the one part that won’t work against a laptop. A cloud E2B sandbox cannot reach your machine’s gitlab.local:8929, so run_sandbox MRs need a reachable instance — a self-hosted E2B domain on the same network, a tunnel, or gitlab.com. Provisioning, webhooks, identity resolution, and all glab MCP read/review/track work fine against local compose.


  • The default MCP tool server is experimental. glab mcp serve is GitLab-official but flagged experimental; pin a known-good glab version if that matters. The community @zereight/mcp-gitlab is the supported alternative for a single shared server. GitLab’s built-in /api/v4/mcp endpoint stays unused until it gains PAT authentication (it is OAuth-only today). See MCP tool server.
  • A single group webhook is not available on every tier — Premium on gitlab.com, absent from Community Edition, and on gitlab.com Free it is accepted and silently never delivered. group_webhook: auto reads the tier from the group and, where the group states none, from its namespace; it uses a group hook where the tier serves one and registers per-project hooks otherwise; see Where the webhook lands. Per-project hooks cover exactly the declared provisioning.projects, so a repository added later needs another run.
  • No composite identity. GitLab’s dual-attribution token mechanism (agent + triggering human) has no public API, so Crewlet’s seats are plain service accounts. Every action is attributed to the agent that took it.

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