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#

The API tokens page

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 in If-None-Match gets 304 Not Modified with 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/ai uses a different, slug-first shape (below), deliberately: the two namespaces are different promises.

Paging

page and page_size on the list routes.

Rate limits

An installation may limit requests per client address and per token. Over the limit, the answer is 429 with Retry-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’s select to 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, project and category at 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} and DELETE /api/rest/issues/{id}/notes/{note_id}

  • GET/POST /api/rest/issues/{id}/files and GET /api/rest/issues/{id}/files/{file_id}

  • POST   /api/rest/issues/{id}/tags and DELETE /api/rest/issues/{id}/tags/{tag_id}

  • GET/POST /api/rest/issues/{id}/monitors and DELETE /api/rest/issues/{id}/monitors/{user_id}

  • POST   /api/rest/issues/{id}/relationships and DELETE /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 needs monitor_delete_others_bug_threshold; me stands 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. PUT is 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 is bugnote_user_edit_threshold and somebody else’s is update_bugnote_threshold, exactly as on the page, and a request that names neither field answers 400 rather than changing nothing quietly.

Projects#

GET, POST, PATCH and DELETE on /api/rest/projects and /api/rest/projects/{id}, plus:

  • /projects/{id}/versions and /projects/{id}/versions/{version_id} (GET, POST, PATCH, DELETE)

  • /projects/{id}/subprojects and /projects/{id}/subprojects/{subproject_id}

  • /projects/{id}/users (GET, POST, PUT) and /projects/{id}/users/{user_id} (PATCH/PUT to change a member’s level in place – locust’s own, where MantisBT answers 405 – and DELETE to take them out). A PATCH of somebody who is not a member answers 404: granting is what the POST is 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, DELETE on users, and PUT /api/rest/users/{id}/reset, for accounts that may administer

  • POST/DELETE /api/rest/users/me/token and /api/rest/users/{user_id}/token/{token_id}

The rest#

  • GET /api/rest/config and PATCH /api/rest/config – the configuration options a client may read or set

  • GET /api/rest/filters, GET /api/rest/filters/{id}, DELETE /api/rest/filters/{id} – saved filters

  • GET /api/rest/lang – the tracker’s own strings, for a client that wants to show them

  • GET /api/rest/pages/issues/view/{id} – everything the issue page needs in one response

  • /api/rest/internal/autocomplete and /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 owes

  • GET, PATCH, DELETE on /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, offset as a duration (2d 14h is 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 them

  • POST /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 outcomes

  • POST /api/rest/meetings – calls one; you become its responsible person

  • PATCH /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 entry

  • POST .../reopen, .../skip, .../close – the other three state changes. Skipping carries the agenda on; closing freezes the meeting, and every write to a closed meeting answers 409.

  • POST .../hand_over – {"responsible_id": N}

  • POST .../invitees – replaces them; on an agreed meeting the people taken off get the cancellation

  • POST .../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 it

  • POST .../notes and PATCH .../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 them

  • GET /api/rest/templates/{id} – one template with its whole tree and every issue it has filed, by hand and on a schedule alike

  • POST /api/rest/templates – {"project_id": N, "name": "...", "summary": "..."}. The summary is the one root issue: a template without one could never be filed.

  • PATCH, DELETE on /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 untouched

  • POST /api/rest/templates/{id}/instantiate – files the tree now, with you as the reporter of every issue, and answers with them

  • POST /api/rest/templates/{id}/nodes, PATCH and DELETE on /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; status is the state the issue starts in and cannot be at or past closed; deadline is 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_id names what it files; the schedule reads as a jour fixe’s does

  • PATCH, DELETE on /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 named occurrence. A second call files nothing twice.

  • GET /api/rest/periodic_tasks/{id}/occurrences – what is coming and what has been filed

  • DELETE /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 work

  • POST /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, and {"project_id": N, "kind": "work", "work_package": N} (the package optional) the working time that is neither. Starting a second run ends the first.

  • POST /api/rest/time_runs/{id}/stop – yours, and only the one that is running

  • POST /api/rest/time_records/confirm – {"run": [N, ...]}. Without more, each run is confirmed as it was measured; minutes_<run id> may lower it, which is what “I forgot to stop” means. A run over eight hours needs long_<run id>, because the process expects a stop and a resume in between.

    rows says what the measured minutes were for, one object per allocation per day – {"run": N, "day": "2026-03-04", "issue_id": N, "work_package": N, "hours": 1.5}, the day optional on a run that touched one, and any of issue_id and work_package left out for a row that names none. A bug must be one you may update in a project that runs the process; a package must be one you may book to in that bug’s project; and a day’s rows may not add up past what the clock measured on that day (409), because a re-allocation divides a measurement and never adds to one. The share_<run id>_<package id> fields of the percentage dialog are gone with it: the hours are stated now.

  • 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, POST on /api/rest/projects/{id}/activities and PATCH /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_id asks for somebody else’s, project_id narrows to one project, and detail=1 asks for the issues behind each cell.

  • GET /api/rest/timesheets/{year}/{month}/export – the same workbook the page’s export produces, byte for byte. shape is declaration (the default) or grid.

  • 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, POST on /api/rest/projects/{id}/work_packages

  • GET, PATCH on /api/rest/work_packages/{id}. budget is in hours; starts_on and ends_on are days. Switching one off needs retain_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: issues with their estimates, and one stretches entry per step of each issue, with from, to, minutes, the step (implementation, control, release, deployment, verification, communication) and whose calendar it came out of. A step nobody is named for carries no person, as the page draws it in a lane of its own.

  • GET /api/rest/plan/resources – per person per ISO week, planned_minutes against available_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, for user_id or for the caller. Each entry has its rank and its reasons, and each reason says what kind it is – answering, deadline (with days_left, work_days, whether the issue is estimated, and the issue the deadline comes from), blocks (how many people, and the soonest day they need it), late or ordinary – 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 – with project_id – is_member and project_hours.

  • PATCH on 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"} with project_id sets 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 answers 400.

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: the note the question is, who was asked, the asker, when, and whether it is still open. 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 answers 403, and a question that is not on this issue 404.

  • 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 answers 403.

  • 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_estimate

Hours, where the project estimates.

due_date

The deadline, under MantisBT’s due_date_view_threshold – which locust, unlike MantisBT, does not set to “nobody” by default.

fast_track

Whether this issue takes the short way, where the project allows it.

work_package

{"id": N, "name": "..."}, the contract the hours are charged to. A PATCH may name one by id, by name or 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_actual

Hours, the sum of what the clock measured. Read-only.

meeting

Which 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 /healthz answers 200/ok while 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.txt

Generated 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/ai

The same thing as JSON, in the shape of the draft-aiendpoint-ai-discovery-00 Internet-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:

  1. An agent may register its own account, but its username begins with bot- and its contact address is the human who runs it.

  2. That human confirms it. Until they do, the token is inert.

  3. An agent never deletes anything, and never rewrites somebody else’s words – only its own notes.

  4. 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_name and purpose. 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 carry children, 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 a 201 after asking for assigned would 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)