The Teamwork Graph API lets a Forge app ask Atlassian who works on what. I wired it into an agent for Rovo, Atlassian's AI assistant, that you can chat with in Jira. Ask it "what is my team working on?" and it reads each member of your Atlassian team and their assigned work items from the graph, then Rovo writes the reply. On our test site it answered all four of my questions correctly, and its actions ran in 0.8 to 2.6 seconds each.
The API went to Preview on 15 October 2026, so search snippets that still say Early Access Program (EAP) are out of date, and even some Atlassian page titles still say EAP. Apps now use "curated" queries, which are ready-made, typed GraphQL queries that Atlassian defines. They also use one permission scope per object type, like read:graph:issue:jira or read:graph:team:atlassian. The old broad scopes, read:graph:jira and read:graph:confluence, are being retired.
I built this demo to learn the APIs. Codegeist 2026, Atlassian's hackathon, has a bonus prize for Teamwork Graph apps, so it's a good moment to learn them. The app is 234 lines of JavaScript, an 81-line manifest and an 11-line prompt, and every file is printed here in full because the repo isn't public.
Most of my time went into small surprises, like an error that arrives as HTTP 200 and a rate limit that hands back an empty list. There are ten of them in the gotchas section.
Note
Prerequisites — You need a Jira Cloud site on a Standard, Premium or Enterprise plan with Rovo turned on. Rovo is paid-only, so a free site may not have it, but a Standard or Premium trial includes it. You need organization admin rights on that site. You need Forge CLI: I used 14.1.0, and 15.0.0 is current and needs Node.js 22.23.4 or newer. You need @forge/api 8.4.0. Last, you need an Atlassian team with a few people who have Jira work items assigned.
[[steps]]
- Create a Jira Forge app — run forge create, pick a Jira template, and keep the app id it writes into manifest.yml.
- Replace the manifest — one rovo:agent, three GET actions and eight scopes (Step 1).
- Add the prompt — prompts/prompt.md tells the agent which action answers which question (Step 1).
- Add the Teamwork Graph client — src/twg.js names every operation, pages and retries (Step 2).
- Add the actions — src/index.js reads the person chatting and the work item on screen (Step 3).
- Deploy and install — an org admin installs it and approves the graph scopes (Step 4).
- Ask it four questions — in Rovo chat on a Jira work item, then check the function logs (Step 5).
Why Teamwork Graph matters for Codegeist 2026
Codegeist 2026 is on Devpost at codegeist2026.devpost.com. The bare codegeist.devpost.com address still shows the 2024 edition, so check which one you're reading. Submissions opened 27 October 2026 at 9:00 am Pacific and close 22 December 2026 at 12:00 pm Pacific. Winners are due around 15 February 2027.
Each of the three categories pays $15,000 for first place, $8,000 for second and $5,000 for third. On top of that, the rules page has four bonus prizes worth $1,500 each, and each is awarded to six projects:
| Bonus prize | What qualifies (from the rules) |
|---|---|
| Best Agents - Rovo/Assignable agents | an app using rovo:agent, rovo:agentConnector, or reusable actions other agents can call |
| Best app with Atlassian MCP | tools exposed through the rovo:mcp module |
| Best apps with Teamwork Graph integration | an app that "connects data into the graph and/or consumes data from the Teamwork Graph API" |
| Best Marketplace published app | published to the Marketplace or submitted for listing |
"The same Submission can win up to one Grand Prize and one Bonus Prize." An app like this demo qualifies for two bonus prizes, Best Agents and Best apps with Teamwork Graph integration, but one entry can only win one of them. The Devpost pages don't say how entrants get a Rovo-enabled site. Atlassian's Rovo pricing page says you can "trial Rovo through a trial of our Standard, Premium, or Enterprise plan".
What the Teamwork Graph API is, and its Preview status
Teamwork Graph is Atlassian's connected model of what lives on a site and in an org: users, teams, Jira work items, comments, goals, Confluence pages, plus whatever third-party connectors add. You talk to it through GraphQL, at one endpoint per site:
POST https://<your-site>.atlassian.net/gateway/api/graphql/twg Atlassian's curated queries page (updated 25 September 2026) says it is "available through Forge's Preview release phase", which in practice means anyone can use it today with the same support as a generally available feature, and breaking changes get "a minimum 1-month deprecation notice" in the Teamwork Graph changelog before they ship. Each curated query is a typed root field, for example atlassianUserAssignedJiraWorkItems. You pass ids in and get typed nodes back. Paging uses first and after. Unlike persisted Cypher queries (Cypher is a graph query language; more on these in the gotchas), curated queries need no registration first.
Two sentences on that page decided my design. The first says "Every row returned is permission-filtered for the authenticated user, so a curated query only ever returns what that user can already see." The second says "Curated queries cap first at 25 ... Requesting more than 25 returns an error." So my agent runs as the person chatting, and it always pages.
The full schema downloads from https://<your-site>.atlassian.net/gateway/api/graphql/twg/schema (basic auth with an API token works for this one URL). Mine was 172 KB. It's the quickest way I found to look up a field name. Jira's API still calls work items issues, so you'll see issueKey and read:graph:issue:jira next to "work item" everywhere.
Step 1. Create a Rovo agent in a Jira Forge app: the manifest and Teamwork Graph scopes
Run forge create, pick a Jira template, and replace the generated manifest.yml with this one. Keep the app.id line: forge create registered the app and wrote its id there. Delete any template src files you won't use. The manifest declares one rovo:agent, three action modules and their functions. All three actions are GET. I did that on purpose. The action docs say agents triggered by automation rules won't call CREATE, UPDATE, DELETE or TRIGGER actions.
modules: rovo:agent: - key: work-context-guide name: Work Context Guide description: Answers who is working on what, and who is involved in a work item, from Teamwork Graph. prompt: resource:agent-prompt;prompt.md conversationStarters: - What am I working on right now? - Who is involved in this work item? - What is my team working on? actions: - whos-working-on - work-item-people - team-workload action: - key: whos-working-on name: Who is working on what function: whosWorkingOn actionVerb: GET description: | Lists the Jira work items a person is assigned to, with status, and the Atlassian teams they belong to. Use it for "what is X working on" or "what am I working on". Leave person empty for the user asking. inputs: person: title: Person type: string required: false description: A person's name or email. Leave empty to mean the user who is asking. - key: work-item-people name: People around a work item function: workItemPeople actionVerb: GET description: | For one Jira work item, lists who is assigned, who reported it, who commented recently, the assignee's teams and any goals the item is linked to. Use it for "who is involved in X" or "who should I ask about X". inputs: issueKey: title: Work item key type: string required: false description: The Jira work item key, for example ORB-12. Leave empty to use the work item open on screen. - key: team-workload name: Team workload function: teamWorkload actionVerb: GET description: | For an Atlassian team, lists its members and what each one is assigned. Use it for "what is my team working on" or "what is the Platform team working on". Leave team empty for the asker's first team. inputs: team: title: Team name type: string required: false description: The team's name, or part of it. Leave empty for the team of the user who is asking. function: - key: whosWorkingOn handler: index.whosWorkingOn - key: workItemPeople handler: index.workItemPeople - key: teamWorkload handler: index.teamWorkload resources: - key: agent-prompt path: prompts permissions: scopes: - read:jira-user - read:jira-work - read:graph:issue:jira - read:graph:issue-status:jira - read:graph:issue-comment:jira - read:graph:user:atlassian - read:graph:team:atlassian - read:graph:goal:atlassian app: runtime: name: nodejs22.x id: ari:cloud:ecosystem::app/<your-app-id> The rovo:agent reference caps the name at 30 characters (ours is 18). Loading the prompt from a resource file needs Forge CLI 10.6.0 or later, and it keeps a long prompt out of the YAML. Rovo reads the action descriptions when it picks an action, so I wrote each one as the question it answers. Watch one mismatch, though. team-workload's description mentions "the Platform team", but the code only finds teams the asker belongs to (Step 3 says why).
The scope names come from the Teamwork Graph scopes page (15 September 2026), whose pattern is read:graph::. The curated queries page adds that a request needs "a scope for every object type the field returns, as well as for the object type it starts from". My queries touch six graph object types (user, team, work item, status, comment, goal), so six graph scopes. The two Jira scopes, read:jira-user and read:jira-work, are for the plain Jira REST calls the actions also make. The scopes page also says the broad read:graph:jira and read:graph:confluence scopes "are being retired", and that Forge caps an app at 50 scopes.
Now create prompts/prompt.md. Here's my whole prompt:
You are Work Context Guide. You help people see who is working on what in their Atlassian site. Use your actions, never guess: - "what is X working on" or "what am I working on": call whos-working-on (leave person empty for the asker). - "who is involved in ORB-12", "who should I ask about this": call work-item-people with the key, or leave it empty when the user is looking at a work item. - "what is my team working on" or a named team: call team-workload. Answer in short plain sentences. Name people and work item keys exactly as the action returns them, and link work items with the webUrl you get. If an action says a source could not be read (a "notes" entry), tell the user that part may be incomplete instead of saying there is nothing. Never invent people, teams or items. Don't skip the prompt's last paragraph. An empty answer from the graph doesn't always mean there is nothing there, and that paragraph tells the agent to say the answer may be incomplete instead of saying there is nothing.
Don't expect a clean forge lint yet: the three handlers don't exist until Step 3, and lint should flag them as missing. You'll verify the whole app there.
Step 2. Call the Teamwork Graph API from Forge: curated queries, paging and retries
Create src/twg.js with this, the whole file. twg() makes one call as the user and retries transient errors. twgAll() reads a paged list (GraphQL calls it a connection) 25 rows at a time. Q holds the five queries.
// Teamwork Graph (TWG) client for Forge backend functions. // // Things that cost real time to learn (measured on a live site, Oct 2026): // - Every operation needs a name shaped `[company]_[operation]` (company >= 3 chars, operation >= 4). // Without it the API answers HTTP 200 with a DataFetchingException, not a 4xx. // - Raw cypherQuery is refused for apps. Use curated queries (typed root fields) or persisted queries. // - Curated connections return at most 25 rows per page. Page with `after`. // - A rate-limited call can come back with zero rows. Treat "empty plus errors" as "could not read", // never as "nothing there", and retry transient errors with backoff. // - @forge/api exposes requestTeamworkGraph on asUser() only. asUser() answers with what the person // chatting can see. Calling the Teamwork Graph API as the app is undocumented and untested here, // so this demo uses asUser(). import api from "@forge/api"; const OP_PREFIX = "rovodemo_"; export const PAGE_SIZE = 25; const TRANSIENT = /RATE_LIMIT|THROTTL|429|502|503|504/i; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export const userAri = (accountId) => `ari:cloud:identity::user/${accountId}`; export const workItemAri = (cloudId, issueId) => `ari:cloud:jira:${cloudId}:issue/${issueId}`; // One GraphQL call as the user, with up to 3 retries on transient errors. // Answers { ok, data, error, ms } and never throws. export async function twg(opName, query, variables) { const operationName = `${OP_PREFIX}${opName}`; const text = query.replace("query OP", `query ${operationName}`); const t0 = Date.now(); for (let attempt = 0; ; attempt++) { let status = 0; let json = null; try { const res = await api.asUser().requestTeamworkGraph(text, variables, operationName); status = res.status; json = await res.json().catch(() => null); } catch (e) { json = { errors: [{ message: String(e?.message || e) }] }; } const errors = Array.isArray(json?.errors) ? json.errors : []; const firstError = errors[0] ? `${errors[0].extensions?.classification || errors[0].extensions?.errorType || "error"}: ${String(errors[0].message).slice(0, 160)}` : status >= 400 ? `http-${status}` : null; if (!firstError && json?.data) return { ok: true, data: json.data, ms: Date.now() - t0 }; if (attempt < 3 && TRANSIENT.test(`${firstError} ${status}`)) { await sleep(Math.min(1200, 200 * 2 ** attempt) + Math.floor(Math.random() * 250)); continue; } return { ok: false, error: firstError || "no-data", data: json?.data || null, ms: Date.now() - t0 }; } } // Reads a curated connection page by page. `field` is the root field name in the query. // Answers { ok, nodes, complete, error }. complete=false means we stopped early (maxPages) or a page failed. export async function twgAll(opName, query, variables, field, maxPages = 4) { const nodes = []; let after = null; for (let page = 0; page < maxPages; page++) { const r = await twg(opName, query, { ...variables, first: PAGE_SIZE, after }); if (!r.ok) return { ok: nodes.length > 0, nodes, complete: false, error: r.error }; const conn = r.data?.[field]; if (!conn || !Array.isArray(conn.edges)) return { ok: false, nodes, complete: false, error: "no-connection" }; for (const e of conn.edges) if (e?.node) nodes.push(e.node); // Keep paging while the API says there is more, even when a page came back short. if (!conn.pageInfo?.hasNextPage || !conn.pageInfo?.endCursor) return { ok: true, nodes, complete: true }; after = conn.pageInfo.endCursor; } return { ok: true, nodes, complete: false, error: "stopped-after-max-pages" }; } export const Q = { userTeams: `query OP($userId: ID!, $first: Int, $after: String) { atlassianUserInTeams(userId: $userId, first: $first, after: $after) { edges { node { ... on AtlassianTeam { id displayName } } } pageInfo { hasNextPage endCursor } } }`, teamMembers: `query OP($teamAri: ID!, $first: Int, $after: String) { atlassianTeamMembers(teamAri: $teamAri, first: $first, after: $after) { edges { node { ... on AtlassianUser { accountId name } } } pageInfo { hasNextPage endCursor } } }`, assignedWork: `query OP($userId: ID!, $first: Int, $after: String) { atlassianUserAssignedJiraWorkItems(userId: $userId, first: $first, after: $after) { edges { node { ... on JiraWorkItem { key summary webUrl status { name } } } } pageInfo { hasNextPage endCursor } } }`, workItemComments: `query OP($id: ID!, $since: String!, $first: Int, $after: String) { jiraWorkItemComments(id: $id, since: $since, first: $first, after: $after) { edges { node { ... on JiraWorkItemComment { createdAt author { name ... on AtlassianUser { accountId } } } } } pageInfo { hasNextPage endCursor } } }`, workItemGoals: `query OP($id: ID!, $first: Int, $after: String) { jiraWorkItemGoals(id: $id, first: $first, after: $after) { edges { node { ... on AtlassianGoal { name url } } } pageInfo { hasNextPage endCursor } } }`, }; Change OP_PREFIX to your own company name first. The operation name rule (more in the gotchas) wants at least 3 characters before the underscore and at least 4 after it. So rovodemo_teamMembers passes, and rovodemo_get would not, because get has only 3 characters. Each query starts with query OP, and twg() swaps in the real name.
A call counts as good only when it has data and no errors, because the API reports some failures with HTTP 200. Transient errors get up to three retries, with backoff from 200 ms capped at 1.2 s plus jitter. twgAll keeps going while hasNextPage is true, stops after four pages by default, and says complete: false when it stopped early or a page failed.
Ids in the graph are ARIs (Atlassian resource identifiers). For a user that's ari☁️identity::user/ plus the accountId. For a Jira work item it's ari☁️jira::issue/, with the numeric issue id, so the key won't do. Both helpers near the top of twg.js build them. You can't verify this file on its own yet; Step 5's logs confirm it.
Step 3. Write the Forge Rovo agent actions: accountId, issue key and ARIs
Create src/index.js with this, again the whole file:
// Work Context Guide: three Rovo actions that answer "who works on what" from Teamwork Graph. import api, { route } from "@forge/api"; import { twg, twgAll, Q, userAri, workItemAri } from "./twg.js"; const log = (event, data) => console.log(JSON.stringify({ demo: event, ...data })); // Rovo puts the person chatting in payload.context.accountId. const askerOf = (payload) => payload?.context?.accountId || null; // Resolve a name or email to one Jira user, as the person asking (they only find who they can see). async function findPerson(text) { const res = await api.asUser().requestJira(route`/rest/api/3/user/search?query=${text}&maxResults=5`); if (!res.ok) return { error: `user search failed (${res.status})` }; const users = (await res.json()).filter((u) => u.accountType === "atlassian" && u.active); if (users.length === 0) return { error: `no active person matches "${text}"` }; if (users.length > 1) { return { error: `"${text}" matches ${users.length} people: ${users.map((u) => u.displayName).join(", ")}. Ask which one.` }; } return { accountId: users[0].accountId, name: users[0].displayName }; } async function personName(accountId) { const res = await api.asUser().requestJira(route`/rest/api/3/user?accountId=${accountId}`); return res.ok ? (await res.json()).displayName : accountId; } const workItemRow = (n) => ({ key: n.key, summary: n.summary, status: n.status?.name || null, webUrl: n.webUrl }); async function teamsOf(accountId, notes) { const r = await twgAll("userTeams", Q.userTeams, { userId: userAri(accountId) }, "atlassianUserInTeams"); if (!r.ok) notes.push(`teams could not be read: ${r.error}`); else if (!r.complete) notes.push("team list may be incomplete"); return r.nodes.map((t) => ({ id: tSource:https://dev.to/mihai_leanzero/atlassian-teamwork-graph-api-forge-rovo-agent-3mcc
