Integrators#
This chapter is for whoever writes something that talks to the tracker: a script, a dashboard, a CI job, a bot, or a language model.
The short version: the API is MantisBT’s. Same paths, same JSON, same status lines, same headers. A script written against MantisBT 2.28’s REST API runs against locust unchanged, which is the point – and that promise is checked, route by route, against a real MantisBT installation.
Two things are locust’s own and are marked where they appear: extra
fields on the issue JSON for features MantisBT has not (estimates,
deadlines, the compliance roles, work packages, meetings), and a
separate namespace, /api/ai, for language models.
locust also adds collections of its own under /api/rest for the
features it added – meetings, templates, the clock, timesheets, work
packages and the planning read models. MantisBT serves no such paths,
so nothing it serves changes and a MantisBT client notices nothing; one
base URL, one token, one error shape.
Every route of both namespaces is described in a served OpenAPI document:
$ curl https://bugs.example.org/api/rest/openapi.json
It needs no token – it documents and reveals no data – and it is built from the router’s own tables, so a route cannot exist without appearing in it.
Getting in#
Figure 32 API Tokens in the account pages: one token per client, shown once.#
Every request carries an API token in the Authorization header
– the token itself, with no scheme word in front of it:
$ curl -H "Authorization: $TOKEN" https://bugs.example.org/api/rest/users/me
A user mints tokens on the API Tokens tab of their account pages. The
token is shown once, at creation; after that only its name and the day
it was last used. Revoking one is immediate.
A token acts as the user who made it, with that user’s access everywhere: there is no scope, no separate permission system, and nothing a token can do that its owner cannot. An installation that allows anonymous browsing also answers read requests with no token at all, under the anonymous user’s access.
Two base paths work, and are the same API:
https://host/api/rest/...– with MantisBT’s rewrite rule;https://host/api/rest/index.php/...– without it.
Everything else of MantisBT’s URLs has moved: pages have lost their
.php and old links answer 308. The API has not moved, on
purpose.
Conventions#
- JSON
Written as PHP’s
json_encode()writes it, down to the escaping, because clients that compare bytes or hash bodies exist.- ETags
Read requests carry an
ETag; sending it back inIf-None-Matchgets304 Not Modifiedwith no body. Use it for polling: it is far cheaper on both sides than fetching the list again.- Status lines
MantisBT’s, with its reason phrases.
- Errors
{"message": "...", "code": N, "localized": "..."}– MantisBT’s shape, with MantisBT’s error numbers./api/aiuses a different, slug-first shape (below), deliberately: the two namespaces are different promises.- Paging
pageandpage_sizeon the list routes.- Rate limits
An installation may limit requests per client address and per token. Over the limit, the answer is
429withRetry-After; a client that respects that header will never notice anything else.
The routes#
Issues#
- GET /api/rest/issues#
A page of issues. Query parameters:
project_id,filter_id(a saved filter),page,page_size, and MantisBT’sselectto narrow the fields returned.
- GET /api/rest/issues/(int: id)#
One issue, with its notes, attachments, relationships, tags, monitors, custom fields and history as the viewer may see them.
- POST /api/rest/issues#
File one.
summary,description,projectandcategoryat least.
- PATCH /api/rest/issues/(int: id)#
Change it. Only the fields you send.
- DELETE /api/rest/issues/(int: id)#
Delete it, if your account may.
Notes, files, tags, monitors and relationships hang off an issue:
POST /api/rest/issues/{id}/notes,PATCH/PUT/api/rest/issues/{id}/notes/{note_id}andDELETE /api/rest/issues/{id}/notes/{note_id}GET/POST/api/rest/issues/{id}/filesandGET /api/rest/issues/{id}/files/{file_id}POST /api/rest/issues/{id}/tagsandDELETE /api/rest/issues/{id}/tags/{tag_id}GET/POST/api/rest/issues/{id}/monitorsandDELETE /api/rest/issues/{id}/monitors/{user_id}POST /api/rest/issues/{id}/relationshipsandDELETE /api/rest/issues/{id}/relationships/{relationship_id}
Three of those are locust’s own, because MantisBT’s own table answers
405 there and left a client with no way to do what its pages do:
- GET /api/rest/issues/(int: id)/monitors#
Who is watching. Under
show_monitor_list_threshold, as the issue’s own JSON is: a caller who may not see the list gets an empty one rather than a refusal, because a refusal would say there is somebody to hide.
- DELETE /api/rest/issues/(int: id)/monitors/(user_id)#
Stops somebody monitoring. Your own monitoring is yours (
monitor_bug_threshold) and somebody else’s needsmonitor_delete_others_bug_threshold;mestands for the caller, so a client need not know its own id. Repeating it answers the same, as the page’s button does.
- PATCH /api/rest/issues/(int: id)/notes/(note_id)#
Corrects a note:
{"text": "..."},{"view_state": {"name": "private"}}, or both.PUTis the same route. What the correction replaced stays in the record as a revision – which is why this exists: deleting the note and adding another loses its number, its date and its place in the history. Editing your own note isbugnote_user_edit_thresholdand somebody else’s isupdate_bugnote_threshold, exactly as on the page, and a request that names neither field answers400rather than changing nothing quietly.
Projects#
GET, POST, PATCH and DELETE on /api/rest/projects
and /api/rest/projects/{id}, plus:
/projects/{id}/versionsand/projects/{id}/versions/{version_id}(GET,POST,PATCH,DELETE)/projects/{id}/subprojectsand/projects/{id}/subprojects/{subproject_id}/projects/{id}/users(GET,POST,PUT) and/projects/{id}/users/{user_id}(PATCH/PUTto change a member’s level in place – locust’s own, where MantisBT answers405– andDELETEto take them out). APATCHof somebody who is not a member answers404: granting is what thePOSTis for, and nobody hands out a level above their own./projects/{id}/handlers– who may be assigned issues here
Users#
GET /api/rest/users/me– who this token belongs to, and what that account may do. The first call a client should make.GET /api/rest/users/{id},GET /api/rest/users/username/{name}POST,PATCH,DELETEon users, andPUT /api/rest/users/{id}/reset, for accounts that may administerPOST/DELETE/api/rest/users/me/tokenand/api/rest/users/{user_id}/token/{token_id}
The rest#
GET /api/rest/configandPATCH /api/rest/config– the configuration options a client may read or setGET /api/rest/filters,GET /api/rest/filters/{id},DELETE /api/rest/filters/{id}– saved filtersGET /api/rest/lang– the tracker’s own strings, for a client that wants to show themGET /api/rest/pages/issues/view/{id}– everything the issue page needs in one response/api/rest/internal/autocompleteand/api/rest/internal/config_display– what MantisBT’s own pages use
locust’s own collections#
MantisBT serves none of these paths, so a MantisBT client never meets
them. They are the same API in every other respect: the same token, the
same JSON, the same {"message": ..., "code": ..., "localized": ...}
on a fault, and the same thresholds – every one of these endpoints asks
the function its page asks, because a rule that holds on the page and
not in the API is not a rule.
Meetings#
A jour fixe is a standing appointment, and a meeting is one
hour. Jour fixes are the management’s (manage_project_threshold);
calling a meeting needs meeting_create_threshold, and running one is
for its responsible person or a manager. A private meeting narrows to
the people in it, so a meeting you may not see answers 404 rather
than 403.
Jour fixes:
GET /api/rest/jour_fixes?project_id=N– the project’s, with their schedules and what each owesGET,PATCH,DELETEon/api/rest/jour_fixes/{id}. The delete is refused once the jour fixe has made a meeting: switch it off instead.POST /api/rest/jour_fixes– defines one. The schedule reads as the page’s form does:frequency_unit(hour, day, week, month, quarter, year),every_n,offsetas a duration (2d 14his Wednesday at two of a week that starts on Monday),spawn_ahead,spawn_from,time_zone,duration_minutes.POST /api/rest/jour_fixes/{id}/invitees–{"invitees": [1, 2]}replaces themPOST /api/rest/jour_fixes/{id}/templates– the templates filed with every meeting. A template a periodic task files is refused: one piece of work has one schedule.POST /api/rest/jour_fixes/{id}/spawn–{"occurrence": "..."}makes that occurrence’s meeting now, with its invitees, its standing agenda and its templates’ issues. A second call makes no second meeting, and a time the schedule does not name makes none at all.
Meetings:
GET /api/rest/meetings?project_id=N,GET /api/rest/meetings/{id}– the one meeting carries its agenda, its minutes and its outcomesPOST /api/rest/meetings– calls one; you become its responsible personPATCH /api/rest/meetings/{id}– the appointment. A field the patch leaves out keeps its value.POST /api/rest/meetings/{id}/agreement– agrees the agenda, which sends the invitation with the calendar entryPOST .../reopen,.../skip,.../close– the other three state changes. Skipping carries the agenda on; closing freezes the meeting, and every write to a closed meeting answers409.POST .../hand_over–{"responsible_id": N}POST .../invitees– replaces them; on an agreed meeting the people taken off get the cancellationPOST .../items–{"issue_id": N}or{"body": "..."}. Whoever runs the meeting puts it straight on the agenda; anybody else proposes it.PATCH .../items/{item_id}–{"state": "agenda" | "rejected" | "deferred", "reason": "..."}. A deferral goes on the next meeting of the series.DELETE .../items/{item_id}– its proposer, or whoever runs itPOST .../notesandPATCH .../notes/{note_id}– the minutes
$ curl -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
-d '{"project_id": 1, "title": "Triage",
"starts_at": "2026-11-11 14:00", "time_zone": "Europe/Zurich",
"duration_minutes": 60, "invitees": [2, 3]}' \
https://bugs.example.org/api/rest/meetings
Templates and periodic tasks#
A template is a named, pre-filled issue tree with one root; a
periodic task is a schedule naming exactly one template. Writing a
template is any developer’s of the project
(manage_template_threshold); filing one additionally needs the right
to report an issue; a schedule is the management’s.
Templates:
GET /api/rest/templates?project_id=N– the project’s, with what files each of themGET /api/rest/templates/{id}– one template with its whole tree and every issue it has filed, by hand and on a schedule alikePOST /api/rest/templates–{"project_id": N, "name": "...", "summary": "..."}. The summary is the one root issue: a template without one could never be filed.PATCH,DELETEon/api/rest/templates/{id}. The delete keeps the issues the template filed – they stop naming a template – and is refused while a periodic task or a jour fixe files it.POST /api/rest/templates/{id}/clone–{"name": "..."}, tree and all; the original is untouchedPOST /api/rest/templates/{id}/instantiate– files the tree now, with you as the reporter of every issue, and answers with themPOST /api/rest/templates/{id}/nodes,PATCHandDELETEon/nodes/{node_id}– the bugs-to-be. The fields are the report form’s, read by the same code, so a custom field that is mandatory on a report is mandatory here;statusis the state the issue starts in and cannot be at or past closed;deadlineis a duration from the occurrence (3d). The root cannot be deleted.
Periodic tasks:
GET /api/rest/periodic_tasks?project_id=N,GET /api/rest/periodic_tasks/{id}POST /api/rest/periodic_tasks–template_idnames what it files; the schedule reads as a jour fixe’s doesPATCH,DELETEon/api/rest/periodic_tasks/{id}. Deleting one leaves its issues where they are; they stop naming a schedule and keep saying which template they came from.POST /api/rest/periodic_tasks/{id}/spawn– files what the schedule owes, or one namedoccurrence. A second call files nothing twice.GET /api/rest/periodic_tasks/{id}/occurrences– what is coming and what has been filedDELETE /api/rest/periodic_tasks/{id}/occurrences–{"issue_id": [N, ...]}withdraws them, which closes the untouched future ones and deletes nothing
A template one schedule files may not be filed by another: pointing a
periodic task at a jour fixe’s agenda template answers 409.
The clock#
A run is somebody’s clock while they work; a record is what a finished run booked, per day, once its owner has confirmed it.
GET /api/rest/time_runs– the viewer’s own runs, and nobody else’s: a clock is a statement about one person’s workPOST /api/rest/time_runs–{"issue_id": N}measures work on an issue, and which step it is follows the issue’s state; the request does not say.{"project_id": N, "kind": "activity", "type_id": N}measures an activity of the project. Starting a second run ends the first.POST /api/rest/time_runs/{id}/stop– yours, and only the one that is runningPOST /api/rest/time_records/confirm–{"run": [N, ...]}.minutes_<run id>may lower what a run booked, which is what “I forgot to stop” means; a figure above what was measured is ignored. A run over eight hours needslong_<run id>, because the process expects a stop and a resume in between.share_<run id>_<package id>divides it between work packages, and the shares of one run must add to 100.POST /api/rest/time_records/{id}/correction–{"minutes": "0.25", "reason": "..."}. A manager of the project the hours belong to, never without a reason, and appended rather than edited.GET,POSTon/api/rest/projects/{id}/activitiesandPATCH /api/rest/projects/{id}/activities/{activity_id}– what a run that is not about an issue is a run of. Switching one off stops it being offered and keeps the hours booked to it.
In a project that books no unclassified hours, starting a run on an
issue nobody has classified answers 409 and says so: the clock is
the thing that needs an account.
Timesheets#
GET /api/rest/timesheets/{year}/{month}– one person’s month: the cells, the days, the working week, what has been declared and the high-water mark each declaration covers.user_idasks for somebody else’s,project_idnarrows to one project, anddetail=1asks for the issues behind each cell.GET /api/rest/timesheets/{year}/{month}/export– the same workbook the page’s export produces, byte for byte.shapeisdeclaration(the default) orgrid.POST /api/rest/timesheets/{year}/{month}/declaration–{"kind": "submitted"}from the person whose sheet it is, or{"kind": "approved", "user_id": N}from somebody who may read their rows. Nobody approves their own.
Your own sheet is always readable. Somebody else’s needs the right to report on their projects’ time and an installation that allows the manager’s view at all – a works agreement may draw the line exactly there. An administrator reads the whole sheet. Asking for the issues behind a cell goes into the audit log for everybody but the owner, and onto the owner’s own sheet.
Work packages#
A work package is the contract or the grant that hours are charged
to: one name an auditor and a developer read the same way. Keeping them
is the management’s; a package is as invisible as the contract it names,
so one you may not manage answers 404.
GET,POSTon/api/rest/projects/{id}/work_packagesGET,PATCHon/api/rest/work_packages/{id}.budgetis in hours;starts_onandends_onare days. Switching one off needsretain_years– at least one, and the default if nothing is said – because switching a package off must never become a quiet way to delete a project’s hours.DELETE /api/rest/work_packages/{id}– an administrator’s, and only once the package is off and the years it said its records must be kept have passed. It is the one operation that changes a sheet of a past month.POST /api/rest/work_packages/{id}/members–{"user_id": N}engages somebody, so they may book to it. An empty list means every member of the project. Somebody who is no member of the project is refused.DELETE /api/rest/work_packages/{id}/members/{user_id}– takes them off. The hours they booked stay: the list says who may book from now on, not who ever did.GET /api/rest/work_packages/{id}/report– the funder’s periodic report: the schedule, the addresses it goes to, the periods that are not settled and what has been sent.
Which issues are on which package is a field of the issue
(work_packages on PATCH /api/rest/issues/{id}), where the
issue’s own thresholds decide it.
The plan#
The three planning tabs are three read models of one statement and one
scheduler, and these three paths are those models. A dashboard outside
the tracker therefore sees what the tab sees, down to the overshoot.
All three take project_id and answer 409 where the project does
not estimate, because then there is no plan to draw.
GET /api/rest/plan/gantt– the work laid out from today:issueswith their estimates, and onestretchesentry per step of each issue, withfrom,to,minutes, thestep(implementation,control,release,deployment,verification,communication) and whose calendar it came out of. A step nobody is named for carries noperson, as the page draws it in a lane of its own.GET /api/rest/plan/resources– per person per ISO week,planned_minutesagainstavailable_minutes. Only the people the project can plan against: an administrator account is not a resource, so none appears here.GET /api/rest/plan/priorities– what to do next and why, foruser_idor for the caller. Each entry has itsrankand itsreasons, and each reason says whatkindit is –answering,deadline(withdays_left,work_days, whether the issue isestimated, and the issue the deadline comesfrom),blocks(how manypeople, and thesoonestday they need it),lateorordinary– so a client can write the same sentence the page writes.
Availability#
A calendar is how much time somebody has: the weekdays they work,
the days that differ, and the minutes one project has of their day.
Your own is always yours to read and to write; somebody else’s is the
project management’s business, through project_id.
GET /api/rest/users/me/availability,GET /api/rest/users/{id}/availability–weekdays(seven numbers of minutes, Monday first, absent where the person keeps the installation’s week),days(the days that differ, each with its kind and remark),may_edit, and – withproject_id–is_memberandproject_hours.PATCHon either –{"weekdays": ["8", "8", "8", "8", "6", "0", "0"]}sets the working week in hours and{"clear_weekdays": 1}gives it back to the installation’s;{"day": "2026-12-24", "day_hours": "0", "day_kind": "public_holiday", "remark": "..."}writes one day and{"remove_day": "2026-12-24"}takes it off again;{"project_hours": "4"}withproject_idsets what that project has of the day, which only somebody who manages it may do. Hours are read exactly as the page’s own field reads them, so an hour that is not a number of hours answers400.
A day caps the day; it does not extend it. A project’s minutes cap it further: a day off stays off however the project divides the week.
The issue verbs the process added#
Three things a compliance project does to an issue that MantisBT’s issue endpoints have no field for. Each asks the page’s own decision function and refuses with the same words, and each is a sub-resource of the issue – so an issue you may not read says about these exactly what it says about itself.
GET /api/rest/issues/{id}/questions– who has been asked what and what came back: thenotethe question is, who wasasked, theasker, when, and whether it is stillopen. A question whose note is private to people you are not among is not listed at all, because listing it would announce that the note exists.DELETE /api/rest/issues/{id}/questions/{question_id}– the asker takes a question back. It is marked withdrawn and nothing is deleted; somebody else’s question answers403, and a question that is not on this issue404.POST /api/rest/issues/{id}/step– the caller takes the phase the issue is in. Which slot that is follows the state; the request does not say. It fills that one role and touches neither the status nor the handler, and a slot that is already somebody’s answers403.PATCH /api/rest/issues/{id}/estimate–{"effort_estimate": "2.5"}, the hours the plan is waiting for. An estimate of nothing is not an estimate (400), and an issue somebody estimated in the meantime is left as it is, so two clients racing do not overwrite one another.
Asking a question and answering one are notes, so they are POST
/api/rest/issues/{id}/notes with the fields the issue API documents;
the notification list has been a field of the issue since it existed.
$ curl -s -H "Authorization: $TOKEN" \
"$BASE/api/rest/plan/priorities?project_id=1"
$ curl -s -X PATCH -H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"weekdays": ["8", "8", "8", "8", "6", "0", "0"]}' \
"$BASE/api/rest/users/me/availability"
$ curl -s -X POST -H "Authorization: $TOKEN" \
-H "Content-Type: application/json" -d '{}' \
"$BASE/api/rest/issues/42/step"
locust’s own fields#
An issue’s JSON carries these in addition to MantisBT’s, each only where the project has the feature and the viewer may see it:
effort_estimateHours, where the project estimates.
due_dateThe deadline, under MantisBT’s
due_date_view_threshold– which locust, unlike MantisBT, does not set to “nobody” by default.fast_trackWhether this issue takes the short way, where the project allows it.
work_package{"id": N, "name": "..."}, the contract the hours are charged to. APATCHmay name one byid, bynameor as a bare string, as it may name a version, and the empty string takes it off again. A package of another project, or one that has been switched off, is refused – except the one the issue already carries, which stays nameable after it has been switched off.effort_actualHours, the sum of what the clock measured. Read-only.
meetingWhich meeting the issue came out of, where one did; notes likewise say which meeting they were written in. Read-only.
A read-only field is refused rather than ignored: a PATCH that
names effort_actual or effective_due_date with a value other
than the one the issue has answers 400 naming the field, and writes
nothing else from that request either. Repeating the value the issue
already has passes, so a client may read an issue, change one field and
send the whole object back.
The four compliance roles – implementer, reviewer, verifier,
communicator – the authority notification pairs and the notification
list (notify) travel the same way in a project that runs the
process, and can be set through POST and PATCH like any other
field. Sending notify replaces the list: an addressee the request
does not repeat is taken off.
One place where a compliance project is stricter than MantisBT:
MantisBT’s issue update checks only that the caller may change a status
at all, never which status, so set_status_threshold applies to its
pages and not to its API. In a project that runs the process the
thresholds are the process’s own and hold on every door, so a PATCH
setting a status answers 403 where the page would refuse it. A
project that does not run the process keeps MantisBT’s behaviour
exactly.
Everything here is additive: a client that ignores unknown fields sees exactly MantisBT’s issue.
Other ways out#
Not everything needs the API:
Any filtered issue list exports as CSV, an Excel workbook or a Word document; the columns are the ones the account configured.
Every list is an RSS feed. The URL carries a per-user key – treat it as a credential, and note that it changes when that user changes their password.
GET /healthzanswers200/okwhile the database answers. It needs no token and exposes nothing; poll it from a load balancer.
Agents#
A language model pointed at a bug in a tracker has to work for its answer: the bug is a page of markup, the API that would hand it over as data needs a token nobody can mint without a browser, and nothing on the site says any of this. locust’s answer is a door that is explicit about what is behind it – and not a second, laxer way into the data.
Reading#
Every GET page that shows data also answers JSON when asked, at the
page’s own URL: Accept: application/json, or ?format=json.
$ curl -H 'Accept: application/json' https://bugs.example.org/view?id=42
The shape is locust’s own and deliberately slim: scalars where
MantisBT’s REST API has objects ("status": "assigned" with
"status_code": 50 beside it, a user as their username). Reading
needs no token wherever reading the page needs none, and the visibility
rules are the same ones the HTML obeys – an agent sees exactly what an
anonymous visitor sees, no more.
GET /api/ai/issues and GET /api/ai/issues/{id} are the same data
under a stable path, and full=1 widens the shape to the REST v1
issue for a client that wants everything.
Reading it as Markdown#
The same URL answers Markdown to a client that asks for Markdown
instead of HTML: Accept: text/markdown, or ?format=markdown
(md is accepted too).
$ curl -H 'Accept: text/markdown' https://bugs.example.org/view?id=42
$ curl 'https://bugs.example.org/api/ai/issues/42?format=markdown'
What comes back is one document: a heading with the issue’s number and summary, its fields as a list, then its description, its steps to reproduce, its additional information, its tags, its attachments, its relationships and its notes – each note headed by who wrote it and when, marked where it is private.
The texts are the texts that were typed. A description or a note
written as Markdown – a list, a table, a fenced patch – arrives as
that Markdown, not escaped and not wrapped in markup; this is the
representation for a reader that reads Markdown, so it is treated as
Markdown. Nothing is linkified, so #123 stays #123.
The document is rendered from the JSON above, which is why the two can never disagree about what you may see: a note the JSON hides is not in the Markdown either, and an issue you may not read answers exactly what it answers to a browser.
A page that has no Markdown of its own – every page but the issue page, for now – answers its HTML, as a page that has no JSON does.
Discovery#
/llms.txtGenerated Markdown: what this tracker is, which projects are readable without a token, the policies (below), how to get a token, what can be read and what can be written, and every error slug.
/.well-known/aiThe same thing as JSON, in the shape of the
draft-aiendpoint-ai-discovery-00Internet-Draft. The draft is an unadopted individual draft and the document says which version it follows, so a client can tell what it is reading.
Every page also carries one comment in its <head> pointing at
/llms.txt and saying that the page answers JSON.
Writing#
Writing needs an account, and an agent’s account is marked as one. The four rules, which the guide states in the second person and the code enforces:
An agent may register its own account, but its username begins with
bot-and its contact address is the human who runs it.That human confirms it. Until they do, the token is inert.
An agent never deletes anything, and never rewrites somebody else’s words – only its own notes.
Everything it does carries its name, and its name is marked as an agent’s wherever it appears.
- POST /api/ai/signup#
username,email(the human’s),real_nameandpurpose. The human is mailed who asked, under what name, for what purpose and from which address, with a link to allow it and a link to reject it; a third link, later, switches it off again. Rejecting removes the account and its token; switching off leaves what it wrote where it is.
- GET /api/ai/me#
What this agent’s account is and what it may do.
- POST /api/ai/issues/tree#
File a whole tree in one statement, all or nothing:
{"request_id": "...", "issue": {...}}where every node may carrychildren, nested as deep as the installation allows. A specification in one project with three implementations in three others, linked as children, is one request.A node may set what the update page can set, at the agent’s level in each project. The status may not be set: it is always
new, and a request that says otherwise is refused rather than quietly corrected – an agent that got a201after asking forassignedwould believe it had assigned the issue.
- POST /api/ai/issues/(int: id)/notes#
Add a note.
- POST /api/ai/issues/(int: id)/files#
Attach a file.
- PATCH /api/ai/notes/(int: id)#
Correct a note the agent itself wrote.
Every write takes a ``request_id``. A timed-out agent retries, and a tree of twenty issues filed twice is the expensive mistake; the tracker remembers the id with a hash of the request and answers the retry with the first result instead of filing again.
Errors on /api/ai are slug-first, so a program can branch on them
without parsing prose:
{"error": "tree-too-large", "message": "...", "ref": "<node>", "field": "..."}
Every slug is listed in /llms.txt.
Limits#
An installation sets how big one tree may be (issues, depth, bytes), how
long a confirmation link lives and how long request ids are remembered,
and can switch the whole door off ([locust-ai] ENABLED = NO), which
removes the discovery documents, the JSON negotiation and /api/ai
and changes nothing else. Signup for agents can be refused separately.
What is deliberately not there: an agent cannot create tags – it
attaches the tracker’s vocabulary, it does not invent it – and there is
no webhook or feed yet, so an agent that wants to know about changes
polls (with If-None-Match, which is what ETags are for here).
A worked example#
Three issues, assigned to you, oldest first, as JSON:
$ curl -s -H "Authorization: $TOKEN" \
'https://bugs.example.org/api/rest/issues?page_size=3&filter_id=assigned'
Add a note to issue 42:
$ curl -s -X POST -H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"text": "Reproduced on 1.2.3, log attached."}' \
https://bugs.example.org/api/rest/issues/42/notes
Poll a list without downloading it twice:
import requests
session = requests.Session()
session.headers["Authorization"] = TOKEN
etag = None
while True:
headers = {"If-None-Match": etag} if etag else {}
r = session.get(f"{BASE}/api/rest/issues?project_id=1", headers=headers)
if r.status_code == 304:
... # nothing changed
else:
etag = r.headers.get("ETag")
handle(r.json()["issues"])
time.sleep(60)