Announcing CIMD support for MCP Client registration
Learn more

Add Linear Tools to a Claude Agent in 50 Lines of Python

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • A complete walkthrough for building a Claude agent in Python that calls Linear on behalf of your users, using Scalekit to handle all the auth.
  • User connects their Linear account once via OAuth (Scalekit stores the token)
  • Your agent calls list_scoped_tools to get Linear tools in Anthropic's native format, no schema writing
  • Standard Claude tool-use loop: Claude decides what to call, your code calls execute_tool with a user identifier, Scalekit makes the Linear API call as that user

Your users expect the Claude agent you've built to create Linear issues, search their backlog, or pull team data using their own Linear identity and permissions.

When the second person starts using your agent, sharing one Linear connection immediately breaks: issues appear under the wrong name, searches leak data the current user shouldn't see, and actions run with the wrong permissions.

The usual path is painful: read Linear's GraphQL API, build an OAuth flow, store and refresh tokens for every user, and hand-write tool schemas. Scalekit lets you skip almost all of that.

Scalekit gives your agent authenticated access to Linear's API and returns tool definitions in Anthropic's native format. You don't write schemas, manage tokens, or build an OAuth flow. Here is the complete working example.

Before you start

  • Python 3.9+
  • pip install anthropic scalekit-sdk-python
  • A Scalekit account (free to start). Sign up at scalekit.com, then in your environment dashboard:
    • Copy your Client ID and Client Secret (usually under Credentials or API Keys).
    • Note your Environment URL (e.g. https://your-env.scalekit.cloud).
    • Under Agent Auth → Connections, create a Linear connection and give it a name (e.g. linear). Use that exact name in the code.
  • A Linear account you can use for testing
  • An Anthropic API key from console.anthropic.com (or a compatible proxy like LiteLLM)

How it fits together

You are building a Claude agent in Python (using the Anthropic SDK). When one of your users chats with the agent and asks it to do something in Linear, your code calls Scalekit.

You pass an identifier that represents the current person in your own system. This identifier comes from your application's normal authentication (after you verify the user via session, JWT, database lookup, etc.). Never trust an identifier coming from the client.

Scalekit looks up the Linear connection that person previously authorized through your app. It then makes the actual Linear API call using their identity and permissions. Your code never sees or manages tokens.

The one-time connection step is also triggered from your app. When a user wants to link their Linear account so your agent can act on their behalf, you generate the authorization link using their identifier and guide them through the OAuth flow.

The complete script

import os import anthropic import scalekit.client from dotenv import find_dotenv, load_dotenv from google.protobuf.json_format import MessageToDict load_dotenv(find_dotenv()) scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions client = anthropic.Anthropic( base_url=os.getenv("ANTHROPIC_BASE_URL"), api_key=os.getenv("ANTHROPIC_API_KEY"), ) # 1. Ensure the user has a connected Linear account response = actions.get_or_create_connected_account( connection_name="linear", identifier="user_123", ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name="linear", identifier="user_123" ) print("Authorize Linear:", link.link) input("Press Enter after authorizing...") # 2. Discover Linear tools (Scalekit returns Anthropic's native format) # NOTE: "linear" here must exactly match the Connection name you gave # the connection in the Scalekit dashboard when you created it (one-time). scoped_response, _ = actions.tools.list_scoped_tools( identifier="user_123", filter={"connection_names": ["linear"]}, page_size=100, ) llm_tools = [ { "name": MessageToDict(tool.tool).get("definition", {}).get("name"), "description": MessageToDict(tool.tool) .get("definition", {}) .get("description", ""), "input_schema": MessageToDict(tool.tool) .get("definition", {}) .get("input_schema", {}), } for tool in scoped_response.tools ] print(f"Discovered {len(llm_tools)} Linear tools") # 3. Run the agent loop messages = [ {"role": "user", "content": "Get my user profile from Linear and tell me which team I'm on"} ] while True: response = client.messages.create( model=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6"), max_tokens=1024, tools=llm_tools, messages=messages, ) if response.stop_reason == "end_turn": print(response.content[0].text) break tool_results = [] for block in response.content: if block.type == "tool_use": print(f" -> Calling: {block.name}") result = actions.execute_tool( tool_name=block.name, identifier="user_123", tool_input=block.input, ) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result.data), }) messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results})

Run it

SCALEKIT_ENVIRONMENT_URL=your-env-url \ SCALEKIT_CLIENT_ID=your-client-id \ SCALEKIT_CLIENT_SECRET=your-client-secret \ ANTHROPIC_API_KEY=your-api-key \ python agent.py

Or create a .env file in the same directory (the script loads it automatically via dotenv):

# Scalekit credentials — get these from your environment dashboard # (Credentials / API Keys section) SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=test_... # Anthropic (or proxy) key ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-6

Critical: The value "linear" (in the filter={"connection_names": ["linear"]} call) must be the exact Connection name you entered when you created the Linear connection in the Scalekit dashboard (one-time step under Agent Auth → Connections). Names are case-sensitive.

You should see output like:

Discovered 10 Linear tools -> Calling: linear_user_get You're logged in as Saif, an Admin on the "Scalekit Agent Kit" team in Linear.

If this is the first run for the identifier you will instead see an authorization link printed. Open it, complete Linear OAuth, press Enter, and the script continues.

A .env file in the same directory is the most convenient way to manage the values (the script uses dotenv):

SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=test_... ANTHROPIC_BASE_URL=https://llm.scalekit.cloud # or https://api.anthropic.com ANTHROPIC_API_KEY=sk-ant-... # or your LiteLLM / proxy key ANTHROPIC_MODEL=claude-sonnet-4-6

How it works

Connect your user

get_or_create_connected_account checks whether this identifier already has an active Linear connection. If not, get_authorization_link generates a URL the user opens to complete the Linear OAuth flow. In production, the identifier comes from your authenticated session. Never from the client.

Discover tools

list_scoped_tools returns the tools available for this user's Linear connection. The key part is the filter:

filter={"connection_names": ["linear"]}

The name you put inside the filter (e.g. "linear") must exactly match the Connection name you created in the Scalekit dashboard. It is case-sensitive.

A mismatch here is the most common reason for an empty tool list.

The agent loop

This is the standard Anthropic tool-use pattern. Send messages to Claude, check if stop_reason indicates a tool call, execute the tool via Scalekit, and send the result back. The loop continues until Claude produces a final text response.

For a more robust version in production you usually add:

  • Basic try/except around execute_tool so errors are fed back to Claude instead of crashing the loop.
  • Logging of every tool call (name + input + result) for debugging and auditing.
  • Optional streaming if you want to show Claude's thinking in real time.

The simple while True loop in the example is enough to get started and works great for most internal tools.

Tool execution

execute_tool passes the tool name, user identifier, and inputs to Scalekit. Scalekit calls the Linear API using the stored token for that user. Your code never touches the token directly.

What a tool definition looks like

When you call list_scoped_tools, Scalekit returns each tool with a definition object containing name, description, and input_schema. Here is what a single tool looks like after extraction:

{ "name": "linear_issue_create", "description": "Create a new issue in Linear", "input_schema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the issue" }, "teamId": { "type": "string", "description": "The ID of the team to create the issue in" }, "description": { "type": "string", "description": "The description of the issue in markdown" }, "priority": { "type": "number", "description": "Priority level (0=none, 1=urgent, 2=high, 3=medium, 4=low)" } }, "required": ["title", "teamId"] } }

This is Anthropic's native tool format. You pass the array directly to client.messages.create() without any conversion.

The Scalekit Python SDK returns protobuf objects, which is why we use MessageToDict (from google.protobuf.json_format) to turn them into plain Python dicts that the Anthropic SDK accepts.

Available Linear tools

list_scoped_tools returns the full set of LLM-optimized tools available for the connected Linear account — typically 10 tools in practice. Here are the core ones grouped by capability:

Category
Tool
Description
Users & Teams
linear_user_get
Get details for the authenticated user or another user by ID (name, email, role, admin status)
linear_team_get
Get a team by ID, including members and key settings
Issues
linear_issue_create
Create a new issue with title, team, description, priority, assignee, labels
linear_issue_update
Update status, priority, assignee, title, or description on an existing issue
linear_issue_search
Full-text and filtered search across issues the user can see
Comments
linear_comment_create
Add a rich-text comment to an issue (supports @mentions and formatting)
Attachments & Relations
linear_attachment_create
Attach a URL, file link, or external resource to an issue
linear_issue_relation_create
Link two issues with a relationship type (blocks, blocked by, duplicate, related)

Additional tools for projects, cycles, labels, and workflow states are usually present depending on the workspace. Scalekit maintains the connector. When Linear evolves their API or GraphQL schema, the tool definitions update behind the scenes — your agent code stays the same. The authoritative list is always in the Linear connector docs.

Try these prompts

The script handles any prompt that maps to the available tools. Here are a few to try beyond the default:

Create an issue from a bug report: "Create a high-priority bug in the Backend team: 'API returns 500 on /users endpoint when email contains a plus sign'. Set priority to urgent."

Claude calls linear_issue_create with the title, description, and priority fields. It may call linear_team_get first to resolve the team ID from the name "Backend." You get back the issue ID and URL.

Update an issue and add a comment: "Mark issue ABC-142 as completed and add a comment saying the fix shipped in v2.4.1."

Claude calls linear_issue_update to change the status, then linear_comment_create to add the note. This is a multi-tool-call turn. Claude can request multiple tool calls in a single response, and the agent loop processes all of them before sending the results back.

Get a team overview: "Who's on the Platform team and what are their roles?"

Claude calls linear_team_get to find the team, then linear_user_get for the members. The response includes display names, roles, and admin status.

Link related issues: "Link issue ABC-142 as blocking ABC-155."

Claude calls linear_issue_relation_create to set up a "blocks" relationship between the two issues. Linear will show this dependency on both issue pages.

Get team + search recent work:

"Show me who is on the Platform team and what issues they closed in the last 7 days."

Claude will likely call linear_team_get first to resolve members, then linear_issue_search with appropriate filters (assignee + date range). This is a good test of multi-step reasoning across tools.

Why this already handles multiple users

The identifier parameter ("user_123" in the example) is what makes this work for teams and products. Each user in your system gets their own identifier. When they connect Linear through the OAuth flow, Scalekit stores their token under that identifier. Every tool call runs with that specific user's permissions.

In production, you resolve the identifier from your authenticated session. After your app verifies who is making the request (via your own login system, session cookie, or JWT), you look up their Scalekit identifier and pass it to every Scalekit call. The same agent code serves every user. You never accept an identifier from the client directly.

# Example: resolve identifier from your authenticated session user = get_authenticated_user(request) # your auth logic identifier = user.scalekit_identifier # stored when they first connected # Now use this identifier for all Scalekit calls scoped_response, _ = actions.tools.list_scoped_tools( identifier=identifier, filter={"connection_names": ["linear"]}, )

If a user hasn't connected Linear yet, get_or_create_connected_account returns a non-ACTIVE status and you send them through the OAuth flow. Once connected, their token is stored and refreshed automatically. You never touch the token directly.

Troubleshooting

Here are the questions developers ask most often when wiring up their first Claude agent with Linear.

Why does my connection stay in PENDING after I authorize?

get_or_create_connected_account returns a status other than ACTIVE when the user hasn't finished (or hasn't started) the OAuth flow for that identifier. Print the authorization link, have the user complete it in their browser, then call get_or_create_connected_account again. In production, make sure your own auth has verified the user before you surface or accept the link.

Why does list_scoped_tools return zero tools?

The name you pass in the filter must exactly match the Connection name you created in the Scalekit dashboard (see the Critical note in the "Run it" section). Names are case-sensitive. An empty result is silent — no error is raised. This is the most common first-run problem.

Why does a tool call fail or return an error?

The two most common causes are:

  • The user's Linear token expired or was revoked (they need to re-authorize via a new link).
  • A required field is missing or has the wrong type in the tool input Claude sent.

Wrap the execute_tool call and feed the error message back to Claude as a tool_result. Claude is usually good at correcting itself on the next turn.

try: result = actions.execute_tool( tool_name=block.name, identifier="user_123", tool_input=block.input, ) content = str(result.data) except Exception as e: content = f"Error: {str(e)}" tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": content, })

Why am I getting an ImportError for MessageToDict?

Install the protobuf package explicitly with pip install protobuf. The scalekit-sdk-python package lists it as a dependency, but clean virtualenvs and some container images don't pull it in automatically.

Why does Claude reply with plain text instead of calling any tools?

The prompt was too vague for the available tools. "Get my Linear user profile and team" works much better than "Tell me about myself." Also double-check that llm_tools is not empty before the first messages.create() call.

What if Linear returns a deprecation or schema error?

Linear sometimes deprecates older GraphQL endpoints. Because Scalekit maintains the connector, the tool definitions are refreshed on their side without any code change on your end. Re-run list_scoped_tools (or restart the script) to pick up the current schema, and pass the exact error text back to Claude so it can adapt its next request.

Explore more

Other connectors. The same code works with Gmail, Slack, GitHub, HubSpot, Notion, and dozens more. Change connection_name and the prompt. See the full connector catalog.

Other frameworks. The same connected-account pattern works with LangChain, CrewAI, Vercel AI SDK, Google ADK, and more. See all framework examples.

Source code. The complete examples live in the agent-auth-examples repo under python/frameworks/anthropic/ and javascript/frameworks/anthropic/.

No items found.
Agent Auth Quickstart
On this page
Share this article
Agent Auth Quickstart

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 million Monthly Active Users
100 Monthly Active Organizations
1 SSO connection
1 SCIM connection
10K Connected Accounts
Unlimited Dev & Prod environments