Announcing CIMD support for MCP Client registration
Learn more

Build a Jira Bug-Triage Agent with LangChain

TL;DR

  • A LangChain bug-triage agent built on the community JiraToolkit authenticates as one static API token. assignee = currentUser() then resolves to the token owner, so the agent triages that account's queue (usually empty) and every comment it writes is authored by a shared bot.
  • A Jira API token (Basic auth) inherits all of the token owner's permissions and ignores OAuth scopes; OAuth 2.0 (3LO) issues per-user, consent-based, scoped (read:jira-work, write:jira-work), short-lived tokens with rotating refresh. Only the second model lets the agent act as the engineer.
  • Triage is a deterministic pipeline with one adjudication step, not an open reasoning loop. Bind a fixed 7-tool subset so the model selects from relevant tools instead of the connector's full catalog. The lever for accuracy is surface reduction, not better prompting.
  • In a multi-user, multi-tenant agent, per-user identity is not optional. Skip it and you get wrong attribution, currentUser() collapse, and cross-tenant blast radius; storing, refreshing, and revoking N engineers' tokens is infrastructure you own regardless of framework.
  • Scalekit's Jira connector resolves each engineer's OAuth token at call time from an identifier, returns native LangChain tools, and refreshes those tokens for you, so the same create_agent build runs correctly attributed for every user.

You wire up the obvious thing. LangChain has a Jira toolkit, your model can write JQL, and in an afternoon you have an agent that reads a bug, finds likely duplicates, sets severity, comments, and transitions the issue. It works in the demo. Then you point it at real engineers and it triages the wrong queue, and every comment it posts is authored by a service account nobody recognizes. That is not a prompt problem. It is an identity problem, and it is baked into the auth model the toolkit quietly picked for you.

Stack: LangChain (create_agent) and the Scalekit Jira connector, Python. Adapt the Scalekit LangChain sample scaffold and have it running in under 30 minutes.

What your bug-triage agent does

The agent takes one incoming bug and runs a fixed sequence. It reads the bug's summary and description, searches open bugs in the same project for candidates, and adjudicates: if the bug duplicates an existing one, it links them as a Duplicate, comments, and transitions the bug to a closed state; if it is new, it assigns a severity, records the rationale as a comment, and transitions the bug to a triaged state. Optionally it files a linked follow-up task for the fix.

Two design commitments matter. The pipeline is deterministic with a single bounded adjudication node, not an autonomous agent free to roam the Jira API. And every action runs as the engineer the work belongs to, not as the agent. The first commitment is a LangChain concern. The second is entirely an auth concern, and it is where the obvious build fails.

The naive build, and the identity it quietly assumes

Here is the setup almost everyone writes first. It is real, current LangChain, and it runs.

# naive_triage.py # The obvious LangChain Jira setup. It runs. It also authenticates as ONE identity. import os from langchain.agents import create_agent from langchain_community.agent_toolkits.jira.toolkit import JiraToolkit from langchain_community.utilities.jira import JiraAPIWrapper # JiraAPIWrapper reads these four env vars. All four describe a SINGLE Atlassian # account: the token owner. Every call the agent makes authenticates as this account. # JIRA_API_TOKEN -> static API token (Basic auth). Inherits ALL of the token # owner's Jira permissions and ignores OAuth scopes entirely. # JIRA_USERNAME -> the token owner's email # JIRA_INSTANCE_URL -> https://your-domain.atlassian.net # JIRA_CLOUD -> "True" for Jira Cloud jira = JiraAPIWrapper() # picks up JIRA_* from the environment toolkit = JiraToolkit.from_jira_api_wrapper(jira) # create_agent is the current LangChain agent constructor (LangGraph runtime). # initialize_agent, AgentExecutor, and create_react_agent are deprecated in v1. agent = create_agent( model="openai:gpt-4o", # swap for any provider create_agent supports tools=toolkit.get_tools(), system_prompt="You triage Jira bugs assigned to the current user.", ) # The model emits a JQL search that includes assignee = currentUser(). result = agent.invoke({ "messages": [ {"role": "user", "content": "Find open bugs assigned to me and triage them."} ] }) print(result["messages"][-1].content)

The failure is silent, which is what makes it dangerous. currentUser() in JQL resolves to whichever principal the request authenticates as. Here that principal is the JIRA_API_TOKEN owner, a service account, so assignee = currentUser() returns the service account's queue and the agent triages nothing an engineer actually owns. When it does write, jira_issue_comment_add and the transition both record the service account as the actor. Three months later a compliance reviewer asks who moved PAY-1421 to Done, and the audit trail says the bot. Nobody can answer who was really accountable.

You cannot fix this with a better prompt. The identity is fixed at the credential layer, one token, one account, for every engineer the agent serves. This is a core example of how tool calling authentication for AI agents goes wrong when the auth model doesn't match the identity model.

The auth path each build puts you on

The toolkit did not choose a static token by accident; that is the only thing JiraAPIWrapper supports. The distinction that matters for an agent is not token versus OAuth in the abstract, it is whether the agent can act as each engineer.

Property
Static API token (Basic auth)
OAuth 2.0 (3LO)
Acting identity
The token owner, for every request
The consenting engineer, per request
assignee = currentUser() resolves to
The token owner (a bot)
The actual engineer
Comment and transition author
The token owner
The actual engineer
Scopes
None; inherits all of the owner's permissions
Granular (read:jira-work, write:jira-work)
Token lifetime
Static, now expires 1 to 365 days after creation
Short-lived access token plus rotating refresh
Multi-user model
One shared credential
One connected account per engineer

For a single-user personal script the static token is fine. For an agent that triages bugs on behalf of a team, it is the wrong foundation: it collapses attribution, over-privileges the agent to the token owner's full access, and, since Atlassian moved all API tokens to mandatory expiry, it silently dies on a schedule you did not set. What the engineer can do, the agent should do, and no more. A shared token cannot express that. Understanding credential ownership across agent tool-calling patterns is essential before you ship to production.

Architecture: a deterministic pipeline with one adjudication node

Triage does not need an autonomous agent that can call any of the connector's Jira tools. It needs a fixed pipeline: gather context, adjudicate once, act within a known set of operations. Giving the model the connector's full catalog would hurt it twice, once on accuracy (more tools in context means worse selection and hallucinated parameters) and once on cost (every tool in context burns tokens before the agent does any work). So the agent is allowed exactly the tools the pipeline uses.

Tool
Role in the pipeline
jira_myself_get
Resolve the acting engineer (accountId, displayName)
jira_issues_search
JQL search for the target bug's candidate duplicates
jira_issue_comment_add
Record the triage decision as a comment
jira_issue_link_create
Link the bug to its duplicate as a Duplicate
jira_issue_transitions_list
Discover valid workflow transitions for the bug
jira_issue_transition
Move the bug to a triaged or closed state
jira_issue_create
File a linked follow-up fix task when the bug is new

Duplicate detection is the one step worth being precise about. Jira has no native semantic dedupe, so the pipeline fetches candidates with a JQL text search over open bugs in the same project, then the adjudication node compares the incoming bug against that candidate set and decides. Linking is directional: Jira's Duplicate type reads as the new bug "duplicates" the canonical issue, so the new bug is the outward side and the canonical issue is the inward side. For a large backlog, replace the JQL text search with an embedding lookup over issue summaries and descriptions; the pipeline shape does not change, only the candidate retrieval does.

Wiring per-user identity into the LangChain agent

The fix is to make the agent act as each engineer, and to do it without hand-rolling OAuth (3LO), token storage, and refresh per engineer. The Scalekit Jira connector holds one connection for your app and one connected account per engineer, keyed by an identifier you choose. At call time it resolves that engineer's token, so assignee = currentUser() and every write resolve to the engineer.

The sequence is discovery, then scope, then execution. First you retrieve the Jira tools authorized for the current engineer's connected account as native LangChain tools. Then you reduce them to the triage surface. Then you hand that surface to create_agent. This is the same pattern described in LangChain tool calling: how it works, where it stops, and how Scalekit completes it.

# triage_agent.py import os from scalekit.client import ScalekitClient from langchain.agents import create_agent # One Scalekit client for the whole service. # Credentials: app.scalekit.com > Developers > API Credentials. scalekit = ScalekitClient( env_url=os.environ["SCALEKIT_ENV_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit.actions # `connection_name` must match the Jira connection name configured in the # Scalekit dashboard exactly. This is the most common integration error. JIRA_CONNECTION = "jira" # The triage pipeline is allowed to touch exactly these Jira tools; nothing else. # Binding a small, fixed surface is the accuracy lever: the model chooses from 7 # relevant tools, not the connector's full catalog. This is surface reduction, # not prompt tuning. TRIAGE_TOOLS = { "jira_myself_get", "jira_issues_search", "jira_issue_comment_add", "jira_issue_link_create", "jira_issue_transitions_list", "jira_issue_transition", "jira_issue_create", } TRIAGE_POLICY = """You are a Jira bug-triage agent acting as the signed-in engineer. For the target bug: 1. Read its summary and description. 2. Call jira_issues_search with JQL that finds candidate open bugs in the same project: issuetype = Bug AND statusCategory != Done, most recent first. 3. Decide whether the target bug duplicates one of the candidates. - If it duplicates an existing issue: call jira_issue_link_create with link_type_name = "Duplicate", passing the target bug as outward_issue_key ("duplicates") and the canonical issue as inward_issue_key ("is duplicated by"). Then add a short jira_issue_comment_add on the target noting the duplicate, list its transitions with jira_issue_transitions_list, and transition it to a closed state with jira_issue_transition. - If it is new: assign a severity (Highest/High/Medium/Low) from the described impact, add a jira_issue_comment_add explaining the rationale, then list and apply a transition to a triaged state. 4. Never call a tool outside the provided set. Do not invent issue keys. Return a one-line summary of the action taken.""" def connect_engineer(identifier: str) -> None: """Ensure this engineer has an ACTIVE Jira connected account. `identifier` is your stable per-user key (user id, email, and so on). Scalekit stores and refreshes this engineer's OAuth token under it. """ account = actions.get_or_create_connected_account( connection_name=JIRA_CONNECTION, identifier=identifier, ) if account.connected_account.status != "ACTIVE": # First run only: send the engineer through Jira's OAuth (3LO) consent once. link = actions.get_authorization_link( connection_name=JIRA_CONNECTION, identifier=identifier, ) print(f"Authorize Jira for {identifier}: {link.link}") input("Press Enter after the engineer has authorized...") def build_triage_agent(identifier: str): """Build a triage agent bound to ONE engineer's identity. Every tool this agent calls executes with that engineer's OAuth token, so assignee = currentUser(), comment authorship, and the audit trail all resolve to the engineer, not a shared bot. """ # Discovery + scope: get the Jira tools authorized for THIS connected account, # as native LangChain tools. page_size avoids missing tools via pagination. all_tools = actions.langchain.get_tools( identifier=identifier, connection_names=[JIRA_CONNECTION], page_size=100, ) # Reduce to the triage surface. The model now picks from 7 tools. tools = [t for t in all_tools if t.name in TRIAGE_TOOLS] return create_agent( model="openai:gpt-4o", # swap for any provider create_agent supports tools=tools, system_prompt=TRIAGE_POLICY, )

The identifier is the whole story. It is the difference between one shared token and per-user identity, and it is the only line of code that changes as you move from a demo to a multi-user agent.

The triage loop, running as the engineer

Run the pipeline once per bug, each run bound to the engineer who owns it. Streaming makes every tool call visible, and every one of those calls executes under that engineer's Jira identity.

def run_triage(identifier: str, project_key: str, bug_key: str) -> None: """Triage one incoming bug as a specific engineer.""" connect_engineer(identifier) agent = build_triage_agent(identifier) task = ( f"Triage bug {bug_key} in project {project_key}. " f"Search for duplicates among open bugs in {project_key} and act per policy." ) # Stream so each tool call is visible. Every call runs as `identifier`'s Jira # user, so currentUser(), comment authors, and transitions are attributed to # the engineer, not to a shared service account. for chunk in agent.stream( {"messages": [{"role": "user", "content": task}]}, stream_mode="values", ): chunk["messages"][-1].pretty_print() # Multi-user: the SAME pipeline, run per engineer, each under their own identity. # No shared credential, no cross-user token, no attribution collapse. if __name__ == "__main__": triage_queue = [ ("eng_ana", "PAY", "PAY-1421"), ("eng_ravi", "PAY", "PAY-1422"), ("eng_mei", "WEB", "WEB-0087"), ] for identifier, project_key, bug_key in triage_queue: run_triage(identifier, project_key, bug_key)

Compare this to the naive build. The pipeline logic is identical; the toolkit and identity underneath it are not. ana's run triages ana's bug as ana, ravi's as ravi, across two different projects, with no shared credential in the loop. assignee = currentUser() finally means what the model assumed it meant.

The credential problem this build still has to solve

Per-user identity fixes attribution, but it introduces the obligation every multi-tenant agent carries. Forty engineers across eight orgs is forty connected accounts to store encrypted, forty OAuth tokens to refresh before they expire, and forty to revoke the day someone leaves. The static-token build hid this obligation behind a single credential; it did not remove it, it made it silently worse, because that one token was over-privileged and shared.

Two failure modes are specific to agents and worth naming.

  • Cross-tenant risk: a shared credential cannot express per-engineer permissions, so an agent acting for one user can reach data scoped to another; per-user connected accounts make correct scoping the default, because scope is derived from what each engineer authorized.
  • Offboarding: disabling an engineer in your IdP does not revoke a Jira API token generated months ago and stored on a laptop, and native Jira webhooks do not emit user lifecycle events, so a token-based agent keeps acting after the human is gone. Revoking a connected account cuts that engineer's access immediately.

Scalekit's Jira connector holds the per-user tokens, refreshes them against Atlassian's expiry, and resolves them at call time from the identifier, so the triage pipeline never sees a raw token and the credential lifecycle is not your code to maintain. This is the same challenge covered in depth for how to handle token refresh for AI agents. The question of who revokes an employee's AI agent access when they leave is a real operational problem this architecture solves by design.

What breaks in production

Watch four things once real bugs flow through it. Token expiry: Atlassian now force-expires API tokens, so any static-token fallback dies on a timer; per-user OAuth with managed refresh is the path that survives. JQL pagination and rate limits: jira_issues_search is paginated and Jira rate-limits by tier, so a wide duplicate search over a large backlog needs cursoring and backoff, not one unbounded call. currentUser() under shared identity: the moment any part of the system falls back to a shared token, the queue and the attribution are silently wrong again. Silent empty results: an under-scoped or unauthorized read returns nothing rather than an error, so confirm each engineer's connected account is ACTIVE before assuming the agent has access.

These production concerns mirror what teams encounter when moving from single-tenant to multi-tenant tool-calling agent auth — the surface area of failure expands significantly with each additional user identity in the system.

Next steps to build it

Configure the Jira connection in the Scalekit dashboard, set your SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET, and confirm the connection name in code matches the dashboard exactly. Swap the triage_queue for your real engineers and project keys, run each engineer through consent once, and watch the streamed tool calls resolve as that engineer. To scale duplicate detection, replace the JQL candidate search with an embedding lookup over issue summaries and descriptions; the pipeline and the auth layer stay the same. To write the priority field directly instead of via a comment, extend TRIAGE_TOOLS with the connector's issue-update tool and add it to the policy.

FAQs

Can I use a bot token or a Jira Service Account for this instead?

You can, and it will demo cleanly, but it reintroduces the exact failure this build removes. A shared identity collapses assignee = currentUser() to the bot's queue, authors every comment and transition as the service account, over-privileges the agent to that account's full permissions, and now expires on Atlassian's mandatory token timer. Per-user connected accounts keep every action attributed to the engineer.

Does actions.langchain.get_tools reduce the tool surface by each engineer's permissions automatically?

No. It returns the connector's Jira tools as native LangChain tools; per-user enforcement happens at execution, when the call runs under that engineer's resolved OAuth token. You reduce the tool surface yourself by binding a subset (here, seven tools), which is the accuracy and cost lever.

How do I get the duplicate link direction right?

Jira's Duplicate link type is directional. The new bug "duplicates" the canonical issue, so pass the new bug as outward_issue_key and the canonical issue as inward_issue_key to jira_issue_link_create. Reversing them inverts the relationship in the issue view.

What happens when an engineer offboards?

Revoke their connected account and the agent immediately loses that engineer's Jira access. A static API token stored locally would keep working after the person is disabled in your IdP, and native Jira webhooks do not emit user lifecycle events, so token-based setups keep acting until someone manually finds and kills the credential.

Do I need MCP for this?

No. This build uses direct tool calling with native LangChain tools. If you standardize on MCP, the same Jira tools can be exposed over a Scalekit-generated MCP endpoint and consumed with langchain-mcp-adapters, but it is not required for the pipeline shown here.

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.