The real problem
Why this is harder than it looks
GitLab’s REST API is well-structured and the documentation is thorough. A proof-of-concept that calls a project endpoint works in an afternoon. The problems start when you try to run this for real users across a multi-tenant product — and GitLab has a few quirks that don’t surface until you hit them in production.
The first thing to understand is that GitLab supports both GitLab.com (SaaS) and self-managed instances. If any of your customers run self-managed GitLab, the base URL for every API call changes per user. Your token storage, request routing, and redirect URIs all need to account for this. It’s not a flag you flip — it changes how you register the OAuth app and how you construct every downstream request.
Then there’s the scope model. GitLab’s OAuth scopes are coarse: the api scope grants full read/write access to everything. If your agent only needs to read repositories, you should use read_api or read_repository instead — but the scope set is fixed at authorization time. Getting this wrong means users over-authorize your app, which enterprise security reviews will flag. Changing scopes later requires re-authorization of every connected user.
Beyond scopes: triggering CI/CD pipelines via the API on GitLab.com requires the authenticated user to have completed identity verification at gitlab.com/-/profile/verify — a requirement that is easy to miss in development but surfaces as a hard 403 in production. The merge request approval tools (gitlab_merge_request_approve, gitlab_merge_request_approvals_get) are restricted to GitLab Premium and above; on Free plans they return 403 Forbidden with no explanation in the error body. And IDs in GitLab are split into a global id and a per-project iid — passing the wrong one to any issue or MR tool returns a silent 404.
On top of all that: per-user token isolation in a multi-tenant system, proactive refresh before expiry, and revocation detection when users disconnect your OAuth app. Each is manageable alone. Together, they add up to weeks of infrastructure work that has nothing to do with what your agent is supposed to do. Scalekit handles all of it.
Capabilities
What your agent can do with GitLab
Once connected, your agent has 110 pre-built tools covering the full GitLab developer workflow:
- Manage repositories end-to-end: read and write files, create and delete branches, inspect commit history and diffs, compare refs, and list repository trees
- Create and triage issues: open issues with labels, assignees, milestones, and due dates; update state; add and manage comments with Markdown and quick actions
- Automate merge requests: create, update, merge, and comment on MRs; retrieve diffs and commit lists; approve and check approval state (Premium)
- Monitor and control CI/CD: list, trigger, cancel, retry, and delete pipelines; inspect individual jobs, retrieve logs, and download artifacts; manage project-level CI/CD variables
- Administer groups and projects: create and update groups, manage group and project membership with numeric access levels, list namespaces, and fork or star projects
- Handle releases and webhooks: create and update tagged releases; manage project webhooks, deploy keys, milestones, labels, and snippets
Setup context
What we’re building
This guide connects a developer assistant agent to GitLab — helping engineers manage issues, review open merge requests, trigger pipelines, and commit file changes across their projects.
🤖
Example agent
Developer assistant triaging issues, reviewing MRs, and managing pipelines on behalf of each engineer
🔐
Auth model
B2B SaaS — each engineer connects their own GitLab account. identifier = your user ID
Setup
1 Setup: One SDK, One credential
Install the Scalekit SDK. The only credential your application manages is the Scalekit API key — no GitLab secrets, no user tokens, nothing belonging to your users.
pip install scalekit-sdk-python
npm install @scalekit-sdk/node
import scalekit.client
import os
from dotenv import load_dotenv
load_dotenv()
scalekit = scalekit.client.ScalekitClient(
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit.actions
import { ScalekitClient } from '@scalekit-sdk/node';
import 'dotenv/config';
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENV_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET
);
const actions = scalekit.actions;
Already have credentials?
Connected Accounts
2 Per-User Auth: Creating connected accounts
Each engineer gets their own Connected Account, giving them a dedicated auth context. The identifier is any unique string from your system — a UUID, email, whatever you use internally.
response = actions.get_or_create_connected_account(
connection_name="gitlab",
identifier="user_gl_456" # your internal user ID
)
connected_account = response.connected_account
print(f"Status: {connected_account.status}")
# Status: PENDING — user hasn't authorized yet
const response = await actions.getOrCreateConnectedAccount({
connectionName: "gitlab",
identifier: "user_gl_456" // your internal user ID
});
const connectedAccount = response.connectedAccount;
console.log(`Status: ${connectedAccount.status}`);
// Status: PENDING — user hasn't authorized yet
This call is idempotent — safe to call on every session start. Returns the existing account if one already exists.
Authorization Flow
3 The authorization flow
The engineer authorizes your agent once. Scalekit generates the OAuth URL with correct scopes, PKCE challenge, and redirect handling pre-configured. After approval, you never see the token.
if connected_account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="gitlab",
identifier="user_gl_456"
)
# Redirect user → GitLab OAuth consent screen
# Scalekit captures the token on callback
return redirect(link.link)
if (connectedAccount.status !== "ACTIVE") {
const { link } = await actions.getAuthorizationLink({
connectionName: "gitlab",
identifier: "user_gl_456"
});
// Redirect user → GitLab OAuth consent screen
// Scalekit captures the token on callback
return redirect(link);
}
Token management is automatic
After the user approves, Scalekit stores encrypted tokens and the connected account moves to ACTIVE. Access
tokens refresh before expiry. If an engineer revokes access from their GitLab settings, the account moves to REVOKED
— no silent failures. Check account.status before critical write operations.
Bring Your Own Credentials for production
By default, Scalekit uses its managed GitLab OAuth app for testing. For production deployments, register your own OAuth application in GitLab User Settings → Applications, paste the Scalekit redirect URI as the callback URL, configure your scopes, then enter your Client ID and Secret in the Scalekit dashboard. Token management stays fully handled.
Calling GitLab
4 Calling GitLab: What your agent writes
With the connected account active, your agent calls GitLab actions using execute_tool. Name the tool, pass parameters. Scalekit handles token retrieval, request construction, and response parsing.
List open issues with filters
Fetch open issues filtered by label, assignee, or date. The updated_before parameter accepts ISO 8601 — useful for stale issue triage.
result = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_issues_list",
tool_input={
"project_id": "my-group/my-repo",
"state": "opened",
"labels": "bug,p1",
"order_by": "updated_at",
"sort": "asc",
"per_page": 50
}
)
# Returns list of issue objects with iid, title, labels, assignees, state
const result = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_issues_list",
toolInput: {
"project_id": "my-group/my-repo",
"state": "opened",
"labels": "bug,p1",
"order_by": "updated_at",
"sort": "asc",
"per_page": 50
}
});
// Returns list of issue objects with iid, title, labels, assignees, state
Create a merge request
Open a merge request from a feature branch. Use remove_source_branch and squash to control post-merge behavior. The description field supports GitLab quick actions like /assign and /label.
result = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_merge_request_create",
tool_input={
"project_id": "my-group/my-repo",
"source_branch": "feature/add-oauth",
"target_branch": "main",
"title": "feat: add OAuth 2.0 authentication",
"description": "Adds GitLab OAuth integration.\n\n/assign @reviewer",
"remove_source_branch": True,
"squash": False
}
)
# Returns: { "iid": 42, "web_url": "https://gitlab.com/...", ... }
const result = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_merge_request_create",
toolInput: {
"project_id": "my-group/my-repo",
"source_branch": "feature/add-oauth",
"target_branch": "main",
"title": "feat: add OAuth 2.0 authentication",
"description": "Adds GitLab OAuth integration.\n\n/assign @reviewer",
"remove_source_branch": true,
"squash": false
}
});
// Returns: { "iid": 42, "web_url": "https://gitlab.com/...", ... }
Read and update a file in a branch
Get a file’s content, modify it, and write it back. Unlike GitHub, GitLab accepts plain string content — no Base64 encoding required. Passing last_commit_id enables conflict detection.
# 1. Read the current file
current = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_file_get",
tool_input={
"project_id": "my-group/my-repo",
"file_path": "docs/CHANGELOG.md",
"ref": "main"
}
)
# 2. Write an updated version back
result = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_file_update",
tool_input={
"project_id": "my-group/my-repo",
"file_path": "docs/CHANGELOG.md",
"branch": "main",
"content": "## v2.5.0\n- Added OAuth\n\n" + current["content"],
"commit_message": "docs: add v2.5.0 changelog entry",
"last_commit_id": current["last_commit_id"] # conflict detection
}
)
// 1. Read the current file
const current = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_file_get",
toolInput: {
"project_id": "my-group/my-repo",
"file_path": "docs/CHANGELOG.md",
"ref": "main"
}
});
// 2. Write an updated version back
const result = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_file_update",
toolInput: {
"project_id": "my-group/my-repo",
"file_path": "docs/CHANGELOG.md",
"branch": "main",
"content": "## v2.5.0\n- Added OAuth\n\n" + current.content,
"commit_message": "docs: add v2.5.0 changelog entry",
"last_commit_id": current.last_commit_id
}
});
Trigger a pipeline and inspect jobs
Trigger a CI/CD pipeline on a branch and then list its jobs to check per-stage status. Note that triggering pipelines on GitLab.com requires the user to have completed identity verification.
pipeline = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_pipeline_create",
tool_input={
"project_id": "my-group/my-repo",
"ref": "main"
}
)
jobs = actions.execute_tool(
identifier="user_gl_456",
tool_name="gitlab_pipeline_jobs_list",
tool_input={
"project_id": "my-group/my-repo",
"pipeline_id": pipeline["data"]["id"]
}
)
for job in jobs["data"]:
print(f"[{job['status']}] {job['name']} — stage: {job['stage']}")
const pipeline = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_pipeline_create",
toolInput: {
"project_id": "my-group/my-repo",
"ref": "main"
}
});
const jobs = await actions.executeTool({
identifier: "user_gl_456",
toolName: "gitlab_pipeline_jobs_list",
toolInput: {
"project_id": "my-group/my-repo",
"pipeline_id": pipeline.data.id
}
});
for (const job of jobs.data) {
console.log(`[${job.status}] ${job.name} — stage: ${job.stage}`);
}
Framework wiring
5 Wiring into your agent framework
Scalekit integrates directly with LangChain. The agent decides what to call; Scalekit handles auth on every invocation. No token plumbing in your agent logic.
from langchain_anthropic import ChatAnthropic
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from scalekit.langchain import get_tools
gl_tools = get_tools(
connection_name="gitlab",
identifier="user_gl_456"
)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a developer assistant. Use the available tools to help manage GitLab projects, issues, merge requests, and pipelines."),
MessagesPlaceholder("chat_history", optional=True),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
agent = create_tool_calling_agent(ChatAnthropic(model="claude-sonnet-4-6"), gl_tools, prompt)
result = AgentExecutor(agent=agent, tools=gl_tools).invoke({
"input": "Show me all open P1 bugs in the backend-api project and create a summary issue linking them"
})
import { ChatAnthropic } from "@langchain/anthropic";
import { AgentExecutor, createToolCallingAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { getTools } from "@scalekit-sdk/langchain";
const glTools = getTools({
connectionName: "gitlab",
identifier: "user_gl_456"
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a developer assistant. Use the available tools to help manage GitLab projects, issues, merge requests, and pipelines."],
new MessagesPlaceholder("chat_history", true),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const agent = await createToolCallingAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-6" }),
tools: glTools,
prompt
});
const result = await AgentExecutor.fromAgentAndTools({
agent,
tools: glTools
}).invoke({
input: "Show me all open P1 bugs in the backend-api project and create a summary issue linking them"
});
Other frameworks supported
Tool reference
All 110 GitLab tools
Grouped by capability. Your agent calls tools by name — no API wrappers to write.
List all projects accessible to the authenticated user, with filters for ownership, membership, visibility, and search
Get a specific project by numeric ID or URL-encoded namespace/path
Create a new project under the user's namespace or a specified group, with visibility and default branch options
Update project settings including name, description, visibility, merge method, and pipeline requirements
Permanently delete a project (async operation). Requires Owner role
Fork a project into a specified namespace
Search within a project across issues, MRs, commits, code blobs, wiki, and more
gitlab_project_forks_list
List all forks of a project
List all namespaces accessible to the current user — useful for resolving where to create or fork a project
Search globally across GitLab for projects, issues, MRs, commits, blobs, and more
List all branches with optional name filter and pagination
Get details of a specific branch including the latest commit
Create a new branch from a branch, tag, or commit SHA
Delete a branch. Protected branches cannot be deleted without first unprotecting
List all tags with name filter, sort, and pagination
Get details of a specific tag including its commit and release notes
Create a lightweight or annotated tag on a branch, tag, or commit SHA
Delete a tag. Protected tags cannot be deleted
List commits filtered by branch, file path, author, or date range
Get detailed commit info by SHA including stats and diff summary
Get the full diff of a commit with all changed file hunks (paginated for large commits)
gitlab_commit_comment_create
Add an inline or general comment to a specific commit
gitlab_commit_comments_list
List all comments on a specific commit
Compare two refs (branches, tags, or SHAs) and return commits and diff between them
Get a file’s raw content and metadata at a specific branch, tag, or commit
Create a new file with a commit message. Content passed as plain string — no Base64 encoding needed
Update an existing file. Pass last_commit_id for conflict detection
Delete a file with a commit message
gitlab_repository_tree_list
List files and directories at a given path and ref. Supports recursive listing
List issues with filters for state, labels, milestone, assignee, author, search, and date ranges
Get a specific issue by its project-level IID (the number shown in the UI)
Create an issue with title, description, labels, assignees, milestone, due date, and confidentiality
Update any issue field — state, labels, assignees, milestone, due date. Only supplied fields are changed
Permanently delete an issue. Requires Owner role. Cannot be undone
List all comments on an issue with sort and pagination
Add a comment to an issue. Supports Markdown and quick actions (e.g., /close, /assign @user)
Update the content of an existing issue comment
Delete a comment from an issue
gitlab_merge_requests_list
List MRs with filters for state, labels, assignee, reviewer, source/target branch, and date
Get a specific MR by its project-level IID
gitlab_merge_request_create
Create an MR with source/target branch, title, assignees, reviewers, labels, squash, and draft options
gitlab_merge_request_update
Update any MR field — title, state, assignees, labels, target branch, draft status
gitlab_merge_request_merge
Merge an open MR. Supports SHA guard, squash override, and custom merge commit message
gitlab_merge_request_approve
Approve an MR. GitLab Premium+ only — returns 403 on Free plans
gitlab_merge_request_approvals_get
Get approval state of an MR — who has approved and who is required to. GitLab Premium+ only
gitlab_merge_request_diff_get
Get the full diff of an MR with all changed file hunks (paginated)
gitlab_merge_request_commits_list
List all commits included in an MR
gitlab_merge_request_notes_list
List all comments on an MR with sort and pagination
gitlab_merge_request_note_create
Add a comment to an MR. Supports Markdown and quick actions
List pipelines filtered by status, branch, commit SHA, or date range
Get detailed pipeline info including status, duration, and triggering user
Trigger a new pipeline on a branch, tag, or SHA. Supports passing pipeline variables. Requires identity verification on GitLab.com
Cancel a running or pending pipeline
Retry all failed jobs in a pipeline
Delete a pipeline and its job traces and artifacts. Permanent
gitlab_pipeline_jobs_list
List all jobs in a pipeline with status, stage, name, and duration
List all jobs across all pipelines in a project, with status filter
Get detailed info about a specific job
Cancel a pending or running job
Retry a specific job with the same configuration
Get the full trace/log output of a job as raw text
gitlab_job_artifacts_download
Download a job’s artifact archive as a binary ZIP
gitlab_project_variables_list
List all CI/CD variables for a project. Masked variable values are hidden
gitlab_project_variable_get
Get a specific CI/CD variable by key
gitlab_project_variable_create
Create a CI/CD variable with masking, protection, and environment scope options
gitlab_project_variable_update
Update an existing CI/CD variable’s value, type, or scope
gitlab_project_variable_delete
Delete a CI/CD variable by key
List all groups accessible to the user with search and ownership filters
Get a group by numeric ID or URL-encoded path
Create a group or subgroup with name, path, visibility, and optional parent
Update group name, path, description, or visibility
Delete a group and all its projects. Requires Owner role. Cannot be undone
gitlab_group_projects_list
List all projects belonging to a group
gitlab_group_members_list
List group members with their access level and expiry
Add a user to a group with a specified access level (10–50) and optional expiry
gitlab_group_member_remove
Remove a user from a group
gitlab_project_members_list
List members of a project with their access level
gitlab_project_member_add
Add a user to a project with a specified access level and optional expiry
gitlab_project_member_remove
Remove a user from a project
Get the authenticated user’s profile — useful for resolving user ID and namespace before other calls
Get a specific user’s public profile by numeric ID
List users by search, username, or active status. Listing all users requires admin access
gitlab_user_projects_list
List all projects owned by a specific user
gitlab_current_user_ssh_keys_list
List all SSH keys for the authenticated user
Add a new SSH public key to the authenticated user’s account with optional expiry
List project milestones filtered by state and search
Get a specific milestone by its numeric ID
Create a milestone with title, description, start date, and due date
Update a milestone’s title, dates, or state (close or reactivate)
Delete a milestone. Linked issues and MRs lose their milestone association
List all labels defined in a project
Create a new label with a name, hex color, and optional description
List project releases with pagination
Get a specific release by tag name
Create a release tied to a tag with name, description, and asset links
Update release name or description
Delete a release (tag is preserved)
gitlab_project_webhooks_list
List all webhooks configured for a project
gitlab_project_webhook_get / create / update / delete
Full CRUD for project webhooks including event triggers and SSL verification
gitlab_deploy_keys_list / create / delete
Manage read-only or read-write SSH deploy keys for a project
gitlab_project_snippets_list / get / create
List, retrieve, and create project-level code snippets
Connector notes
GitLab-specific behavior
IID vs ID — use the wrong one and you get a silent 404
GitLab issues and merge requests each have a global id and a per-project iid. The iid is the number users see in the UI (e.g., #42). All tools that accept issue_iid or merge_request_iid expect the iid. Passing a global id returns a 404 with no explanation. When in doubt, call gitlab_issues_list or gitlab_merge_requests_list first to confirm you have the right iid.
Pipeline triggering requires identity verification on GitLab.com
On GitLab.com, calling gitlab_pipeline_create via API requires the authenticated user to have completed identity verification at gitlab.com/-/profile/verify. Without it the API returns 403 Forbidden. This check does not apply to self-managed instances. Surface a clear prompt to affected users rather than returning a generic error.
Approval tools require GitLab Premium
gitlab_merge_request_approve and gitlab_merge_request_approvals_get return 403 Forbidden on GitLab Free plans. If your agent targets mixed-tier customers, check for this error and handle it gracefully rather than treating it as an auth failure.
Self-managed GitLab instances use a different base URL
GitLab.com tools use https://gitlab.com as the base URL automatically. For self-managed instances, set your instance hostname as the base URL in your Scalekit connection configuration. The OAuth app must also be registered on the self-managed instance, not on GitLab.com.
Infrastructure decision
Why not build this yourself
The GitLab OAuth flow is documented. Token storage isn’t technically hard. But here’s what you’re actually signing up for:
PROBLEM 01
Scope selection is irreversible at authorization time — over-scope and enterprise security reviews block your app; under-scope and tools fail at runtime with no clear error
PROBLEM 02
Self-managed GitLab instances change the base URL per user — token storage, request routing, and OAuth app registration all need per-instance handling
PROBLEM 03
Proactive token refresh before expiry — not reactive on a 401 mid-pipeline or mid-MR operation, where a failed call leaves your workflow in a partial state
PROBLEM 04
Per-user token isolation across a multi-tenant system — one engineer’s GitLab credentials must never be accessible to another, even across the same group or organization
That’s one connector. Your agent product will eventually need GitHub, Jira, Slack, Notion, and whatever else your customers ask for. Each has its own OAuth quirks and failure modes.
Scalekit maintains every connector. You maintain none of them.