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.
Configuration
Section titled “Configuration”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 PATUnlike integrations.github, three fields differ:
urlis 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_secretis required when enabled — inbound webhooks are verified by the GitLab 19.1+ Standard-Webhooks HMAC signature (webhook-signatureheader) and nothing else. The weaker plainX-Gitlab-Tokenscheme is intentionally unsupported; gitlab.com always runs ≥ 19.1 and the docker-compose test instance runsgitlab-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: whencrewlet gitlab provision --webhook-url …runs and that var is unset, the provisioner generates awhsec_…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 oneGET …/participantsREST call per comment/state-change event, made with this credential (any group member’s PAT withread_api; the provisioner mints a dedicated read-onlycrewlet-engineaccount for the referenced${VAR}automatically). Without it, routing degrades to payload-derived targets — directed events are unaffected. This mirrorsintegrations.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.
Per-role wiring
Section titled “Per-role wiring”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 recipeThe 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.
MCP tool server
Section titled “MCP tool server”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
glabbinary 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 serveis flagged experimental by GitLab (“may be unstable or removed”). It’s official and actively developed; pin a known-goodglabversion 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.
How code authoring works
Section titled “How code authoring works”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.
Provisioning
Section titled “Provisioning”crewlet gitlab provision <company.yaml> is a one-shot, idempotent reconcile from company config to GitLab state, runnable any number of times.
crewlet gitlab provision company.yaml \ --provision-token "$GITLAB_PROVISION_TOKEN" \ --webhook-url https://engine.example.com/webhooks/gitlab \ --env-file .env.gitlab| Flag | Description |
|---|---|
config (positional) | Path to the Tier B company YAML |
--provision-token | Operator credential (see the permission matrix below). Falls back to $GITLAB_PROVISION_TOKEN, then $GITLAB_ADMIN_TOKEN |
--mode group|instance | Create accounts under the top-level group (default; group-Owner-callable on GitLab.com) or instance-wide (self-managed admin) |
--webhook-url URL | Engine webhook endpoint to register on the group/projects (e.g. https://engine.example.com/webhooks/gitlab). Omit to skip webhook registration |
--secret-store | Write 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 PATH | Env file to append/update minted tokens into (default: .env.gitlab). Ignored with --secret-store |
--print | Print export VAR=token lines to stdout instead of writing an env file |
--rotate | Rotate each managed service-account token (re-mints with a fresh expiry) |
--decommission-removed | Delete service accounts whose seats left the config (soft delete). Requires provisioning.username_prefix so managed accounts can be scoped safely |
--token-expiry-days N | Expiry 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
403here — even with a valid group-Ownerapitoken — is GitLab.com’s identity-verification gate, not a scope problem; the run aborts withProvisioning cannot proceed: …pointing at identity verification. - Declared projects exist. Each
provisioning.projectsentry is checked withGET /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.
What a run does
Section titled “What a run does”For each agent seat that declares GitLab credentials (presence of mcp_env.gitlab, the same convention GitHub uses):
-
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 isrole.name; the email isrole.emailwhen set. -
Ensure membership. Add the account to the configured top-level group at its access level (
access_level, withaccess_levelsper-handle overrides), plus any explicitly listedprojects.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 asmaintainer. 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 atdeveloperand add per-projectmaintainermemberships out of band if you need it. Alternatively, keepdeveloperand relax each project’s protected-branch “Allowed to merge” to include Developers (a project setting the provisioner does not manage). -
Ensure a token. The provisioner derives the env-var name from the config itself — it scans the seat’s
mcp_env.gitlabvalues and itssandbox.env.GITLAB_TOKENfor 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 fromprovisioning.token_scopes, default[api]; expiry from--token-expiry-days) namedcrewlet-provision:<handle>and writesVAR=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. -
Ensure the engine’s routing account. When
integrations.gitlab.tokenreferences a${VAR}, a dedicatedcrewlet-engineservice account (prefixed like the seats) is provisioned with Reporter access and aread_api-scoped PAT minted into that var — the read-only credential participants-based routing uses. It rotates with--rotateand is never decommissioned. -
Ensure webhooks (only when
--webhook-urlis 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 thesigning_secretas the hook’ssigning_token(the caller-supplied, write-onlywhsec_…value GitLab uses to sign thewebhook-signatureheader; 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_secretpoints at a${VAR}that is unset, the provisioner generates a validwhsec_<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-urlgiven); 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/pipelineevent 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. Sogroup_webhookchooses 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 listedprojectsentry.
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.
Token sinks
Section titled “Token sinks”Two sinks, chosen by flag:
--secret-store: write each minted value into the encryptedsecret_valuestable under the same${VAR}name the config references. The engine consults that table ahead of the environment, so thesource+ restart step disappears entirely. This is the recommended sink once a Tier A keyring is configured.--env-file PATH(default.env.gitlab): append/updateVAR=tokenlines — the file the operator feeds the engine. Created0600and 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: emitexport VAR=tokenlines to stdout for shellevalinstead of writing a file.
Rotation & decommission
Section titled “Rotation & decommission”--rotatere-mints each managed token via GitLab’s rotate endpoint, passing an explicitexpires_atat--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 unlessprovisioning.username_prefixis 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.
| Target | Required 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-managed | An 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.
Prerequisites (GitLab.com)
Section titled “Prerequisites (GitLab.com)”Two GitLab.com-specific conditions must hold before the service-accounts API will answer, or provisioning aborts on the first preflight with a 403:
- 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
apiPAT still gets a403until it verifies. This is the most common cause of a403on an otherwise-correct setup. provisioning.groupis 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). Pointingprovisioning.groupat a personal namespace or a subgroup path yields a403. 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.
Webhooks
Section titled “Webhooks”Inbound GitLab events arrive at POST /webhooks/gitlab.
Verification
Section titled “Verification”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.
Event routing
Section titled “Event routing”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.tokenread credential (oneGET …/participantscall per event); without it — or when the lookup fails — routing degrades to the payload-derived assignees.
Hook (object_kind) | Routed to | event_type |
|---|---|---|
issue | On 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 fallback | issue.assigned, issue.mention, issue.close |
merge_request | On 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 fallback | note.mention, note.comment |
pipeline | Only 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 |
emoji | Parsed 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.
Notification prompts
Section titled “Notification prompts”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_type | Prompt behaviour |
|---|---|
merge_request.review_requested | Actionable — read the diff, approve or leave review comments, notify the requester |
merge_request.assigned / issue.assigned | Actionable — read the MR/issue, do the work (code changes via the sandbox), report back |
note.mention, issue.mention, merge_request.mention | Actionable — 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.
Identity registration
Section titled “Identity registration”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.
GitHub vs GitLab identity
Section titled “GitHub vs GitLab identity”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 provisioncan go from “role incompany.yaml” to “agent with working credentials” with zero UI clicks, and GitHub cannot.
Local testing
Section titled “Local testing”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:
docker compose --profile gitlab up -dscripts/gitlab-dev-bootstrap.sh # mint a root token, open the SSRF allowlist, seed a group, provisionThe profile ships one service:
gitlab—gitlab/gitlab-ee:latestserved athttp://gitlab.local:8929. The EE image is deliberate: service accounts are a Free-tier feature that lives in EE-edition code, so the FOSSgitlab-ceimage 404s on the/service_accountsAPI — an unlicensedgitlab-eeimage 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.
Walkthrough (Nimbus against local GitLab)
Section titled “Walkthrough (Nimbus against local GitLab)”-
Resolve
gitlab.local. The instance’sexternal_urlishttp://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 -
Bring up the stack and seed GitLab. The
gitlabprofile also pulls in Pulsar + Postgres:Terminal window docker compose --profile gitlab up -d # first boot of GitLab takes 3–6 minscripts/gitlab-dev-bootstrap.sh # waits, mints a root PAT, opens the webhook SSRF allowlist, seeds nimbus-hq/nimbuscoreThe script prints the root PAT (
glpat-crewlet-dev-bootstrap) and the UI login (root/$GITLAB_ROOT_PASSWORD). Local unlicensedgitlab-eeruns 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/-/readinessreturns404from the host — that’s expected, not a failure. GitLab’s monitoring endpoints (/-/readiness,/-/liveness,/-/health,/-/metrics) are IP-restricted to127.0.0.0/8/::1/128by 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 psshowing 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. -
Make a local-pointed copy of the config. Rewrite the three host references (the default repo
.gitignorecovers*.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 -
Provision the agents. The root PAT is the operator credential; the
--webhook-urltargets the engine’s embedded API on port 80 (api.port: 80in the quickstart’s Tier A file), reachable from the GitLab container viahost.docker.internal. NoGITLAB_SIGNING_SECRETneeded — 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.gitlabOnly
nimbus-hq/nimbuscoreis 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). -
Run the engine with the minted tokens sourced (add your base runtime
config.yaml— providers, queue, DB — per the quickstart). Withapi.port: 80in 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’sapicomment). (Do not also start the standalonecrewlet run apihere — the two would fight over the port; that command is for split deployments only):Terminal window source .env.gitlabcrewlet run config.yaml --import-company nimbus.local.company.yaml -
Drive the loop in the UI (
http://gitlab.local:8929): create an issue innimbus-hq/nimbuscoreand 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 ownglabMCP 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.
Limitations
Section titled “Limitations”- The default MCP tool server is experimental.
glab mcp serveis GitLab-official but flagged experimental; pin a known-goodglabversion if that matters. The community@zereight/mcp-gitlabis the supported alternative for a single shared server. GitLab’s built-in/api/v4/mcpendpoint 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: autouses 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.