Skip to content

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.


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_… — 19.1+ Standard-Webhooks HMAC — REQUIRED
token: "${GITLAB_ENGINE_TOKEN}" # optional read credential → participants-based routing
provisioning: # consumed ONLY by `crewlet gitlab provision`, ignored by the engine
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)
token_scopes: [api] # scopes minted on each service-account PAT

Unlike integrations.github, three fields differ:

  • 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 implied.
  • signing_secret is required when enabled — inbound webhooks are verified by the GitLab 19.1+ Standard-Webhooks HMAC signature (webhook-signature header) and nothing else. The weaker plain X-Gitlab-Token scheme is intentionally unsupported; gitlab.com always runs ≥ 19.1 and the docker-compose test instance runs gitlab-ee:latest, so the signing token is always available. Self-managed GitLab older than 19.1 is not supported. Point it at a ${VAR} and you don’t even have to invent a value: when crewlet gitlab provision --webhook-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.
  • 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; the provisioner mints a dedicated read-only crewlet-engine account for the referenced ${VAR} automatically). 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 is read only by the provisioning CLI — the engine never looks at it. Its fields drive the reconcile described under Provisioning.


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

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. Execute has a run_sandbox tool: the planner lists it in tools_needed, 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 Execute 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 example config, not engine code — the engine ships no git-auth, the same stance as GitHub. The Nimbus GitLab example (examples/nimbus.company.yaml) carries a scoped credential helper reading $GITLAB_TOKEN for the GitLab host only, insteadOf rewrites for SSH-style remotes, and a brief telling the coding agent to just clone. Two GitLab-specific wrinkles versus the GitHub recipe: the basic-auth username is arbitrary for PAT auth (GitLab ignores it — the token is the password), 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 the recipe templates the host from integrations.gitlab.url rather than hardcoding it.

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
crewlet gitlab provision company.yaml \
--provision-token "$GITLAB_PROVISION_TOKEN" \
--webhook-url https://engine.example.com/webhooks/gitlab \
--env-file .env.gitlab
FlagDescription
config (positional)Path to the Tier B company YAML
--provision-tokenOperator credential (see the permission matrix below). Falls back to $GITLAB_PROVISION_TOKEN, then $GITLAB_ADMIN_TOKEN
--mode group|instanceCreate accounts under the top-level group (default; group-Owner-callable on GitLab.com) or instance-wide (self-managed admin)
--webhook-url URLEngine webhook endpoint to register on the group/projects (e.g. https://engine.example.com/webhooks/gitlab). Omit to skip webhook registration
--secret-storeWrite minted credentials into the encrypted secret_values table instead of an env file — the engine reads them back directly, so there is nothing to source. Needs a Tier A keyring + DSN (--bootstrap / --dsn)
--env-file PATHEnv file to append/update minted tokens into (default: .env.gitlab). Ignored with --secret-store
--printPrint export VAR=token lines to stdout instead of writing an env file
--rotateRotate each managed service-account token (re-mints with a fresh expiry)
--decommission-removedDelete service accounts whose seats left the config (soft delete). Requires provisioning.username_prefix so managed accounts can be scoped safely
--token-expiry-days NExpiry for minted/rotated tokens (default 364; GitLab.com Free max is 365). 0 omits expires_at so the instance default/max applies

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> — under the configured group (or instance-wide with --mode instance); create it if missing. The display name is role.name; the email is role.email when set.

  2. Ensure membership. Add the account to the configured top-level group at its access level (access_level, with access_levels per-handle overrides), plus any explicitly listed projects.

    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-provision:<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.

  4. Ensure the engine’s routing account. When integrations.gitlab.token references a ${VAR}, a dedicated crewlet-engine service account (prefixed like the seats) is provisioned with Reporter access and a read_api-scoped PAT minted into that var — the read-only credential participants-based routing uses. It rotates with --rotate and is never decommissioned.

  5. Ensure webhooks (only when --webhook-url is passed). Register the events the router acts on — issues_events, merge_requests_events, note_events, pipeline_events, emoji_events (push events off by default: inbox noise) — 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 with the same URL are updated, not duplicated.

    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). This only happens when a hook is actually being created (--webhook-url given); provide the var yourself to pin a specific secret.

    One hook 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 — GitLab’s docs are explicit that a group hook and a project hook on the same events both fire for an in-project event, i.e. double delivery. So group_webhook chooses exactly one level, never both:

    • auto (default) — try one group hook; on success stop (it covers all projects). Only if the group-hooks API is unavailable (older/Free self-managed) does it fall back to per-project hooks, recording a note. This is the correct free/paid-agnostic default.
    • true — group hook only; fail if the group-hooks API is unavailable (no silent per-project fallback).
    • false — per-project hooks only, one per listed projects entry.

    Transition caveat. If a prior run created per-project hooks (group hooks were unavailable then) and a later run establishes a group hook (now available), the reconcile does not remove the old per-project hooks — you would get double delivery until you delete them. Deleting a redundant project hook is a manual step.

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.

Two sinks, chosen by flag:

  • --secret-store: write each minted value into the encrypted secret_values table under the same ${VAR} name the config references. The engine consults that table ahead of the environment, so the source + restart step disappears entirely. This is the recommended sink once a Tier A keyring is configured.
  • --env-file PATH (default .env.gitlab): append/update VAR=token lines — the file the operator feeds the engine. Created 0600 and written through on every mint, so a crash mid-run cannot leave a minted-but-unrecorded credential. 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 instead of writing a file.
  • --rotate re-mints each managed token via GitLab’s rotate endpoint, passing an explicit expires_at at --token-expiry-days (GitLab’s bare rotate defaults the new token to one week, so the explicit expiry matters), then updates the chosen sink. On the GitLab.com Free tier — where every PAT expires within 365 days — this is the once-a-year cron candidate.
  • --decommission-removed (explicit, never default) soft-deletes service accounts whose seats left the config (contributions reassigned to the ghost user). It refuses to act unless provisioning.username_prefix is set, so it can identify managed accounts without touching un-prefixed ones.

Permission matrix — the operator credential

Section titled “Permission matrix — the operator credential”

The provisioner’s own credential is an operator credential, passed by --provision-token / $GITLAB_PROVISION_TOKEN / $GITLAB_ADMIN_TOKEN, and is never stored in company config.

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, project hooks) is group-Owner-callable on GitLab.com
Self-managedAn instance admin PAT (use --mode instance), or a group Owner PAT with the instance setting allow_top_level_group_owners_to_create_service_accounts enabled

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.

Verification is the GitLab signing token only. The webhook-signature header is verified as a 19.1+ Standard-Webhooks HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body} (constant-time compare, ±5-minute timestamp tolerance; the whsec_… secret’s base64 payload is the HMAC key). An invalid or missing signature → 401; a request that arrives before signing_secret is configured → 500.

The provisioner sets that secret as each hook’s signing_token when it registers webhooks. The weaker plain X-Gitlab-Token scheme is not supported.

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.

parse_gitlab_webhook turns a payload into a list of per-recipient notifications (one comment can @-mention several agents; one update can add several assignees/reviewers). Each notification targets exactly one GitLab username via metadata.gitlab_username, resolved to an agent or human seat through the HandleRegistry, and carries project, mr_iid/issue_iid, url, and an event_type of "{object_kind}.{action}".

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; self-suppression is off for this one)pipeline.failed
emojiParsed but not routed (no reliable target on award events)

The comment author is always excluded from fan-out, and each recipient gets one notification per event — the first, highest-signal reason wins (a mentioned participant pings as a mention, not as thread activity).

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.

GitLab webhooks use GitLabNotificationPrompt (src/crewlet/notifications/notification_prompts/gitlab.py), which gives tailored, actionable prompts to the events that name a thing to act on:

event_typePrompt behaviour
merge_request.review_requestedActionable — read the diff, approve or leave review comments, notify the requester
merge_request.assigned / issue.assignedActionable — read the MR/issue, do the work (code changes via the sandbox), report back
note.mention, issue.mention, merge_request.mentionActionable — evaluate whether you were actually asked to do something, then respond on the same thread
Everything else (approvals, non-mention comments, MR merge/close, participant thread activity)Generic fallback — evaluate relevance and skip if not actionable

Review requests, assignments, and failed pipelines are treated as pointer events (requires_recon) — they name a diff / thread / job log to fetch before the agent has the real context, so the Plan-phase relevance prefetches skip their aux-LLM call.


At engine startup (and on every org hot-reload), register_gitlab_accounts_from_org resolves each role’s GitLab username so webhooks can route to it. For every role with a token in mcp_env.gitlab, it calls GET {integrations.gitlab.url}/api/v4/user with that PAT and registers the returned (gitlab, username) → agent handle mapping in the HandleRegistry. This is REST, not an MCP round-trip: 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 role’s own token. Roles whose ${VAR} credential is unresolved are skipped (no MCP instance was started for them either); if two seats resolve to the same username, the mapping is dropped with a warning rather than misrouting.

Human seats register their contact.gitlab_username through the same CONTACT_FIELD_BY_TRANSPORT map, so a founder’s or teammate’s GitLab activity is attributed by name in agent prompts and webhook sender resolution — with no extra plumbing.


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:

  • gitlabgitlab/gitlab-ee:latest 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 on GitLab, and it targets gitlab.com as shipped (url: https://gitlab.com, GITLAB_HOST: gitlab.com, the git-auth recipe scoped to gitlab.com). To exercise it against the local compose instance instead, point those host references at http://gitlab.local:8929 — everything else is identical.

  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 the stack and seed GitLab. The gitlab profile also pulls in Pulsar + Postgres:

    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 local-pointed copy of the config. Rewrite the three host references (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
    sed -e 's#https://gitlab.com#http://gitlab.local:8929#g' \
    -e 's#GITLAB_HOST: gitlab.com#GITLAB_HOST: http://gitlab.local:8929#g' \
    -e 's#gitlab.com#gitlab.local:8929#g' \
    examples/nimbus.company.yaml > nimbus.local.company.yaml
  4. Provision the agents. The root PAT is the operator credential; the --webhook-url targets the engine’s embedded API on port 80 (api.port: 80 in the quickstart’s Tier A file), reachable from the GitLab container via host.docker.internal. No GITLAB_SIGNING_SECRET needed — the provisioner generates one and writes it to the env file:

    Terminal window
    GITLAB_PROVISION_TOKEN=glpat-crewlet-dev-bootstrap \
    crewlet gitlab provision nimbus.local.company.yaml \
    --webhook-url http://host.docker.internal:80/webhooks/gitlab \
    --env-file .env.gitlab

    Only nimbus-hq/nimbuscore is seeded by the bootstrap, so the config’s other projects (nimbusk0s, console, website) are dropped with a note — create them in the UI if you want them, then re-run (the reconcile is idempotent).

  5. Run the engine with the minted tokens sourced (add your base runtime config.yaml — providers, queue, DB — per the quickstart). With api.port: 80 in that Tier A file, the engine’s embedded API receives the GitLab webhooks and serves the dashboard — one process is the whole stack; binding 80 needs privileged-port access on Linux (see the example config’s api comment). (Do not also start the standalone crewlet run api here — the two would fight over the port; that command is for split deployments only):

    Terminal window
    source .env.gitlab
    crewlet run config.yaml --import-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 a Premium feature. On the Free tier the provisioner registers per-project hooks; group_webhook: auto uses a group hook only when the instance accepts it.
  • 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.

Generated from crewlet/crewlet v0.1.0 at b40ea18.