I built a small Forge app to learn the Teamwork Graph API. It has one rovo:agent with three GET actions: what am I working on, who is involved in this work item, what is my team working on.
It works, and each action ran in 0.8 to 2.6 seconds on our test site.
Most of the time went into failures that don’t look like failures. Here they are in order.
The install needs an org admin
Graph scopes are per object type now, like read:graph:issue:jira or read:graph:team:atlassian. You need one for every object type a query starts from or returns (scopes page). The broad read:graph:jira and read:graph:confluence are being retired.
The app needed eight scopes, and only an org admin can install it.
No allowlisting was needed in October 2026, although Call the Teamwork Graph API still lists an “App not allowlisted” error.
A scope change is a major version. Deploy, then run forge install --upgrade, or the site keeps the old version.
Apps can’t send raw Cypher
Older examples send a cypherQuery string, and apps get refused with a pointer to persisted queries. The migration page says “Apps can no longer send arbitrary Cypher text to Teamwork Graph at request time”.
The curated queries covered everything the agent needed, for example atlassianUserAssignedJiraWorkItems and atlassianTeamMembers. For other traversals there are persisted Cypher queries. In our tests they registered only from a logged-in browser session. An API token didn’t work.
A missing operation name fails with HTTP 200
Send a query without a proper operation name and you get this, word for word:
You must set an operation name of the shape `[company]_[operation]` with company >= 3 chars and operation >= 4 chars and max length 100
It arrives as HTTP 200 with a DataFetchingException. A res.ok check passes and your code carries on with no data. rovodemo_teamMembers is fine, rovodemo_get is not.
Here is the client we ended up with. It names every call and treats a call as good only when it has data and no errors:
import api from "@forge/api";
const OP_PREFIX = "rovodemo_"; // your company name, >= 3 chars
const TRANSIENT = /RATE_LIMIT|THROTTL|429|502|503|504/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function twg(opName, query, variables) {
const operationName = `${OP_PREFIX}${opName}`;
const text = query.replace("query OP", `query ${operationName}`);
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 || "error"}: ${String(errors[0].message).slice(0, 160)}`
: status >= 400 ? `http-${status}` : null;
// HTTP 200 is not success here: data AND no errors, or it failed
if (!firstError && json?.data) return { ok: true, data: json.data };
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 };
}
}
Every query string starts with query OP and twg() swaps in the real name, so the name in the text always matches the operationName argument. A call looks like twg("teamMembers", Q.teamMembers, { teamAri, first: 25 }), where Q.teamMembers is a query OP(...) string on atlassianTeamMembers (the tutorial prints all five queries).
asUser is the documented path
I installed @forge/api 8.4.0 and read its types. requestTeamworkGraph is on asUser() only, and AsAppFetchMethods doesn’t have it.
Curated results are “permission-filtered for the authenticated user”, so the agent sees what the person chatting can see. That’s what you want in chat. It also worked for a test user who had never opened the app.
The requestTeamworkGraph bridge page says frontend calls “always run as the current user” and sends backend code to asUser(). Calling it as the app from the backend is still open in Teamwork Graph: calling the API asApp, where I put what the package shows.
If you find a way, I found nothing in the docs that filters those rows per person, so treat them as unfiltered and filter them yourself before an agent sees them. We didn’t test that. The same identity question comes back if you expose these actions to MCP clients with a rovo:mcp module. For Atlassian’s own MCP server, Gabriela’s Atlassian MCP server permissions vs data security policy shows what MCP clients can read and write once an admin allows them.
Rate limits and empty results look the same
One caller in our tests hit RATE_LIMIT_EXCEEDED after about 128 calls in 20 seconds. After that it got roughly 4 calls a second.
A rate-limited query could come back with zero rows. Read that as “nothing there” and the agent tells the user their team has no work. The client retries with backoff. When a source still fails, the action returns a notes array so the agent can say the answer may be incomplete.
Two more fail the same way. Curated queries return at most 25 rows a page, and a short page can still have hasNextPage true, so keep paging until it’s false.
A missing scope also gives no error, the rows are left out. We saw rows keep coming back right after removing a scope. The scopes page says changes take about a minute, which may explain it, but a query working without a scope doesn’t mean the scope is optional.
Rovo is stricter than the action docs
The action docs allow 5 MB per action. In Rovo chat about 1 MB came back intact, and about 1.1 MB failed with a 500 before Rovo retried with a smaller ask.
Rovo also gave up on an attempt after about 20 seconds. The invocation limits page gives an action function 55 seconds. Plan for well under 1 MB and 20 seconds.
All three actions are GET on purpose. The action docs say agents run from automation rules won’t call CREATE, UPDATE, DELETE or TRIGGER actions. Non-GET verbs were unreliable in chat too, see Rovo agent action fails when added to existing Forge app.
Smaller ones
- When we opened Rovo from the Atlassian home chat, one action ran in a different installation with empty app storage. From the Rovo panel in Jira it read the Jira installation, so keep actions stateless.
- Basic auth with an API token can’t read graph data. Data fields return “Auth category: UNAUTHENTICATED is not allowed in service graph_store”. The schema download does work with basic auth.
- Rovo is paid only, so a free developer site may have no agent chat at all. My reply in Rovo chat agents not available on free developer site for Codegeist 2026 has a way to get a Rovo-enabled test site. Curated queries did answer on a free site in our tests.
It’s a Preview API, so check these against the Teamwork Graph changelog before you rely on them. The manifest, prompt, both source files, the agent’s answers and the timings are in the full Teamwork Graph Rovo agent tutorial.