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. An agent is personal or it belongs to the workspace:
| Kind | Who can make it | Token |
|---|---|---|
| Personal agent | Any member. It belongs to the person who made it. | Vantik returns its token one time, when you make it. |
| Workspace agent | Only an admin. | Vantik never makes a token for it. |
To make a personal agent in the webapp, open Settings, and click API under My account. In Access tokens, set Acts as to Its own identity — an agent. Read How to get a token.
To make an agent with the API, send POST /v1/users/agents. The body takes
name, and optional ownership and scopes. ownership is personal, the
default, or workspace. The server refuses workspace from a member who is
not an admin. A workspace agent holds no token, so it cannot call the API.
Read
Personal agents and workspace agents.
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,
"modules": [{ "id": "…", "name": "Server" }],
"capability": { "id": "…", "name": "Webhooks" },
"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" }],
"criteria": [
{ "id": "…", "body": "The pool holds under 200 requests a second", "completed": false, "completedAt": null }
],
"comments": [
{
"id": "…",
"author": { "id": "…", "fullname": "Jane Doe" },
"createdAt": "…",
"updatedAt": "…",
"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.
modulesholds the modules that the issue names, in the order of the issue.capabilityis null when the issue delivers no capability.criteriais the Definition of Done of the issue, in list order. To tick a criterion, sendPOST /v1/checklist_items/:checklistItemIdwith{ "completed": true }.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.
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. |
moduleIds | Module ids, with a comma between two of them. An issue matches if it names one of these modules. |
capabilityId | One capability id. An issue matches if it delivers this capability. |
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.
Database triggers update searchable issue text and comments in the same write transaction. The vector worker updates semantic search after a text change or a model change. Keyword search remains available before the new vectors are ready.
PostgreSQL combines full-text search, typo matches, and optional semantic search.
Leave EMBEDDINGS_SOURCE unset for keyword search only.
Set it to local for a CPU model that sends no document text to a hosted endpoint.
Set it to hosted for an OpenAI-compatible endpoint that receives document and search text.
See Search for model settings, offline use, and privacy limits.
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.