How to work with agents
An agent is one more client of the API, and there is no separate "agent API". This page collects the endpoints and the conventions that are most important when the caller is an LLM. The agent reads the full context of a task in one request, keeps each payload small, exchanges markdown and not rich-text JSON, and searches the past work to see if somebody solved the problem before.
Everything on this page uses the same authentication with a personal access token as the other parts of the API.
The better method for most agents is a connection over MCP, and not a direct call to these endpoints. See How to connect an MCP client. The tools there use the REST layer below. This page describes that layer, for a client that you build directly against the API.
An agent account, and what it can do
An agent account is its own identity in the workspace. The workspace therefore
records each edit against the agent, and not against the person who connected
it. Make an agent account from Settings → Agents, or with
POST /v1/users/agents. Only an administrator can use that endpoint.
An agent has scopes, and the server checks them on every request:
| Scope | What it covers |
|---|---|
read | Read the board, search, and get a task. |
write | File, update, comment, and close. |
delete | Remove an issue, a comment, a label, and the other records. |
The HTTP method of a request gives the scope that the request needs. GET
reads, DELETE deletes, and every other method writes. A route can declare
something different. A few reads arrive as a POST, because their input travels
in the body. POST /v1/issues/filter is the board itself, and it counts as a
read.
A new agent gets read and write. You give delete deliberately, so an agent
does the whole issue workflow without the one verb that nobody can undo. To
change the scopes, send scopes when you make the account:
{ "name": "Release Bot", "scopes": ["read", "write", "delete"] }
A request outside the scopes of an agent gets a 403, and the error names the
scope that the request needed. An agent from a time before the scopes has the
default scopes, and not all of them. A person has no scopes, because this
applies to an agent account only.
The MCP endpoint is an exception, because one POST there covers every tool,
and some of those tools only read. The tools reach the API over loopback, so the
server checks the scope of the work that they do.
Markdown, and not Tiptap JSON
The server holds a rich-text field as Tiptap JSON. An issue description and a comment body are rich-text fields. The API converts the format at the edge, so an agent never touches Tiptap:
| Direction | Issue field | Comment field |
|---|---|---|
| Read | descriptionMarkdown | bodyMarkdown |
| Write | descriptionMarkdown | bodyMarkdown |
A read returns the markdown field with the raw description or body. A write
accepts one field or the other. If you send the raw field and the markdown
field together, the raw Tiptap field wins. Therefore send only
descriptionMarkdown or bodyMarkdown, unless you write Tiptap deliberately.
How to read the full context of a task in one call
GET /v1/issues/:issueId/context
This endpoint returns everything that an agent needs to start work on an issue. Each id already has its name for a reader, so you make no second call to translate a UUID. No raw Tiptap JSON is in the response.
{
"id": "…",
"key": "ENG-42",
"title": "Connections exhausted under load",
"descriptionMarkdown": "…",
"state": { "id": "…", "name": "In Progress", "category": "STARTED" },
"assignee": { "id": "…", "fullname": "Jane Doe" },
"team": { "id": "…", "identifier": "ENG", "name": "Engineering" },
"labels": [{ "id": "…", "name": "bug" }],
"priority": 2,
"estimate": null,
"dueDate": null,
"project": null,
"cycle": null,
"parent": { "id": "…", "key": "ENG-40", "title": "…" },
"subIssues": [{ "id": "…", "key": "ENG-43", "title": "…", "stateCategory": "COMPLETED" }],
"relations": [{ "type": "BLOCKED", "issue": { "id": "…", "key": "ENG-12", "title": "…" } }],
"linkedIssues": [{ "id": "…", "url": "https://github.com/…", "title": "PR #12" }],
"comments": [
{
"id": "…",
"author": { "id": "…", "fullname": "Jane Doe" },
"createdAt": "…",
"bodyMarkdown": "…",
"replies": []
}
],
"history": [
{ "at": "…", "actor": "Jane Doe", "change": "state", "from": "Todo", "to": "In Progress" }
],
"createdAt": "…",
"updatedAt": "…"
}
Notes:
- The response holds no deleted comment, no deleted relation, and no deleted sub-issue.
relations[].typealways takes the point of view of the issue that you ask for. The database holds the rowENG-50 BLOCKS ENG-42. When you ask for the context of ENG-42, that row reads asBLOCKED.historyis short. It holds one entry for each change that a person can read:state,assignee,priority,estimate,team,project,cycle,parent, andlabel. Each id has its name. The response holds no row where nothing changed for a reader.
Two parts of the same data have their own endpoint, for when you need only that part:
GET /v1/issues/:issueId/comments # in time order, with the replies one level below
GET /v1/issues/:issueId/history # the short history array
How to close an issue with a resolution
There is no endpoint for "close". To close an issue, move it to a workflow state
with the COMPLETED category:
- Send
GET /v1/:teamId/workflowsand select the state withcategory: "COMPLETED". - Send the explanation as a comment. Use
POST /v1/issue_comments?issueId=…withbodyMarkdown. - Send
POST /v1/issues/:issueId?teamId=…with{ "stateId": "<completed state>" }.
The order is important, and you send the comment before the change of state. The
search returns the resolution of an issue from the last top-level comment at or
before the change into COMPLETED. The next section gives more detail.
How to find a fix from the past
GET /v1/search?workspaceId=…&query=pg+pool&stateCategory=COMPLETED
The search index covers the title of an issue, its description, and the bodies of its comments. You therefore find a fix that only a comment describes. These are the parameters:
| Parameter | Description |
|---|---|
query | Free text. |
workspaceId | Optional. The default is the workspace of your session. |
limit | The maximum number of hits. The default is 10, and the maximum is 100. |
threshold | The threshold for the vector distance. The default is 0.8. |
stateCategory | The workflow categories for the search, with a comma between two of them. For example COMPLETED, or STARTED,COMPLETED. |
Only query is necessary. workspaceId defaults to the workspace of your
session. If you omit a numeric parameter, or if the server cannot parse it, the
server uses the default value. The request does not fail.
Each hit holds descriptionMarkdown, stateCategory, and resolutionSnippet.
The snippet is the first 500 characters of the comment that explains the
resolution. One call therefore tells you that a similar issue exists, and
how somebody fixed it. GET /v1/search/similar_issues?workspaceId=…&issueId=…
returns the same fields.
The server makes the embeddings again after a change to an issue or to one of its comments. It does this work asynchronously, so a new comment becomes searchable in a few seconds, and not immediately.
The search runs fully inside your deployment. Typesense makes the embeddings in its own process, with a model that it includes. The server sends no title, no description, and no comment to an external service.
How to keep a list payload small
POST /v1/issues/filter
By default this endpoint returns a full row for every issue that matches, and
that payload is expensive for a context window. If you send page, perPage,
orderBy, or view, the endpoint returns pages instead:
{
"filters": { "state": { "filterType": "IS", "value": ["<stateId>"] } },
"workspaceId": "…",
"page": 1, // the default is 1
"perPage": 50, // the default is 50; a value above 200 gets a 400
"orderBy": "updatedAt", // updatedAt (the default) | createdAt | number | priority, always from high to low
"view": "list" // list (the default) | full
}
{ "issues": [ … ], "page": 1, "perPage": 50, "total": 123 }
view: "list"returnsid,key,title,stateId,stateCategory,assigneeId,priority,labelIds,projectId, andupdatedAt. It returns no description.view: "full"returns every column of the issue, anddescriptionMarkdown.
If you send none of the four parameters, the endpoint keeps its original behaviour and returns an array of full issues. A client from an earlier time therefore continues to work.