API¶
The API is versioned under /v1. The OpenAPI document is at
/v1/openapi.json and interactive docs at /v1/docs.
Authentication¶
Send one of:
Authorization: Bearer <token>: an API token (ira_t_...) or a session token (ira_s_..., from login withuse_cookie: false);- the
ira_sessioncookie set byPOST /v1/auth/login. Requests that change something (any method butGET,HEADandOPTIONS) must then also sendX-Ira-Request: 1, and are refused when the browser says they came from another site (see Cross-site request forgery).
A missing or invalid credential gives 401; a valid one without the needed
permission gives 403. Every error body is {"detail": "..."} (a list of
field errors on 422).
| Route | Needs |
|---|---|
GET /healthz, GET /v1/auth/status, POST /v1/auth/login |
nothing |
GET /v1/setup/status, POST /v1/setup/owner, POST /v1/auth/setup |
nothing (the setup code) |
POST /v1/invites/lookup, POST /v1/invites/accept |
nothing (the invitation token) |
GET /v1/auth/me, GET /v1/info, POST /v1/auth/logout, GET /v1/me/pin |
any credential |
PUT /v1/me/pin |
a session with actions |
GET /v1/me/sessions, DELETE /v1/me/sessions/{id}, POST /v1/me/sessions/revoke-others |
a session (any role) |
POST /v1/ask, POST /v1/ask/stream, GET /v1/conversations, GET /v1/conversations/{id} |
ask |
POST /v1/voice/transcribe, POST /v1/voice/speak |
voice |
POST /v1/actions/{id}/confirm, POST /v1/actions/{id}/cancel |
actions (and your own PIN to confirm) |
POST /v1/actions/{id}/approve |
ask, and being the person who asked (Tier 2 only) |
GET /v1/me/settings, GET /v1/me/activity |
ask |
PATCH /v1/me/settings |
a session with ask |
POST /v1/devices/pair |
nothing (the one-time pairing code) |
GET /v1/downloads, GET /v1/downloads/{name} |
nothing: Ira for Mac is open source |
GET /v1/devices/ws |
a device token (irad_...), and nothing else |
GET /v1/me/devices, GET /v1/me/skills, GET /v1/me/devices/pairings/{id}, GET /v1/me/events, GET /v1/me/events/stream |
ask |
POST /v1/me/devices/pairing, PATCH/DELETE /v1/me/devices/{id}, DELETE /v1/me/events |
a session with ask (and your PIN to pair or switch a skill on) |
GET, POST /v1/admin/devices, PATCH/DELETE /v1/admin/devices/{id} |
a session with admin.write |
GET /v1/admin/* |
admin.read (GET /v1/admin/users/invites and GET /v1/admin/sessions: users) |
POST, PUT, PATCH, DELETE under /v1/admin |
a session with admin.write, or users for users, invitations and sessions |
Auth¶
Returns the user, their permissions and the session expiry. With
use_cookie: true (the default) the session is an HTTP-only, SameSite=Strict
cookie; with false the response carries session_token instead. Five failed
sign-ins for one email or address within 15 minutes give 429 for 15 minutes.
The first owner is created with the setup code: see First-run setup.
Ask¶
{
"text": "Tuesday has 14:00 and 14:30 free.",
"conversation_id": "kitchen-tablet",
"action": null,
"tools_used": ["find_slots"],
"tool_calls": [
{"id": "call_1", "name": "find_slots", "arguments": {"day": "2026-10-13"},
"ok": true, "result": "{\"slots\": [\"14:00\", \"14:30\"]}", "truncated": false}
],
"model": "openai:gpt-4o-mini"
}
Omit conversation_id to start a new conversation; the response returns the
new id. Conversation ids are private to the user who made them.
tool_calls (AskToolCall[]) lists each tool the model called, in order:
id: unique within the turn; the model's own call id (the transcript'stool_call_id), with a suffix such as-2if the model repeated one;arguments: what the model passed, as a JSON object ({}when it was not valid JSON);ok: false when the call failed (bad arguments, an unknown or switched-off tool, or an error from the tool). Proposing an action is ok;result: the result the model was given, as text, cut to its first 500 characters;truncatedis then true and the text ends with…. An error reads{"error": "..."}.
These are results of the caller's own question, which the model has already
seen. tools_used (just the names) stays for v0.1 clients.
When the turn proposed an action, action is an ActionView:
{"id": "act_1f2e...", "tool": "cancel_appointment",
"summary": "Cancel appointment #3: Riley Demo on Tuesday 13 October at 15:00",
"status": "pending", "expires_in": 120,
"confirm_roles": ["owner", "admin"], "can_confirm": false}
tier is 2 when a "yes" from the person who asked is enough, and 3 when it
needs a PIN; can_approve says whether the caller may say that yes (Tier 2
only). The yes can be POST /v1/actions/{id}/approve, or a short "yes",
"haan" or "theek hai" sent as the next message of the same conversation; "no"
or "nahi" cancels. Neither reaches a model.
confirm_roles are the roles that may confirm this action:
actions.tools.<name>.confirm_roles in ira.yaml, or else
actions.confirm_roles. can_confirm is for the caller: true when their
credential has the actions permission and their role is in
confirm_roles. When it is false, a client shows no PIN box ("your role
can't confirm this"). It does not say whether the caller has set a PIN;
GET /v1/me/pin does. Confirm and cancel responses carry the same fields.
Streaming¶
POST /v1/ask/stream takes the same body and answers with server-sent events,
one JSON object per data: line. Each is a StreamEvent in
/v1/openapi.json, told apart by type:
type |
Fields | Meaning |
|---|---|---|
start |
conversation_id |
the turn began |
tool |
id, name, arguments |
a tool was called; arguments is a JSON object |
tool_result |
id, name, ok, result, truncated |
that call (same id) finished; result is cut at 500 characters, as in tool_calls above |
action |
action |
an action was proposed: an ActionView with confirm_roles and can_confirm, as above |
delta |
text |
more answer text |
error |
message |
no model could answer |
done |
text, model, conversation_id |
the full answer; always last |
A tool_result follows its tool before the next tool starts. When a call
proposes an action, its action event comes right after its tool_result.
Your conversations¶
Any credential with ask (a phone token with scope ask too) can read back
its user's own conversations from the web chat and the API:
| Route | Returns |
|---|---|
GET /v1/conversations?limit=&before=&before_id= |
MyConversationRow[]: id, channel (web or api), started_at, last_at, turns, preview (the first question, cut to 120 characters); by last activity, newest first. Page with the last row's last_at as before and its id as before_id; X-Has-More says whether more follow (see Paging) |
GET /v1/conversations/{id} |
MessageRow[]: the messages, with tool calls and tool results, as in the admin transcript; 404 if it is not yours |
id is the conversation_id the client sent to /v1/ask: send it again to
continue. Someone else's conversation answers 404, as an unknown one does,
so the answer does not say whether it exists. Telegram chats and
conversations that record no user (from before accounts) are not listed
here; the admin list has them.
An assistant message carries model, the provider:model that wrote it
(each tool round can fall back to a different one); it is null when no
model answered, on other roles, and on messages stored before this field.
A tool result that proposed an action carries that action as it stands now,
in action (MessageAction: id, tool, summary, status,
expires_at). Its content keeps what the model was told
(pending_confirmation), so read the status from action: an expired
proposal says expired there. action is null on every other message,
and on proposals stored before this field that could not be matched to
their action when the database was upgraded.
Voice¶
POST /v1/voice/transcribe takes a WAV body (Content-Type: audio/wav, up to
10 MB and max_audio_seconds) and returns {"text": ..., "seconds": ...}.
Mono 16-bit 16 kHz is ideal; other PCM WAVs are converted. Non-WAV data gives
415; no speech-to-text configured gives 503.
POST /v1/voice/speak takes {"text": ..., "shorten": true} and returns
audio/mpeg. With shorten, markdown is stripped and long text is cut at a
sentence.
Actions¶
The PIN is the confirming user's own (set with PUT /v1/me/pin or
ira pin set --user), and their role must be allowed to confirm this action
(actions.confirm_roles in ira.yaml, narrowed per tool by
actions.tools.<name>.confirm_roles).
| Status | HTTP | Meaning |
|---|---|---|
executed |
200 | it ran; result holds the tool's return value |
failed |
200 | it ran and failed; message says why |
unknown |
200 | it ran out of time (a device that did not answer) or a restart cut it off: it may or may not have happened, so check before asking again |
wrong_pin |
403 | attempts_left before the lock |
not_allowed |
403 | your role may not confirm this action; no PIN attempt is used |
not_found |
404 | no such action |
already_done |
409 | executed, cancelled or failed before |
no_pin |
409 | you have not set a PIN yet; the message says to set one in the Ira app's menu or ask an owner to reset it |
expired |
410 | past its 120 seconds, whether or not anything had marked it expired yet |
When it ran (executed, or failed), Ira carries the conversation on: the
model sees what the action returned (or why it failed) and goes on with what
was asked. reply is that answer, with reply_message_id; show it as
Ira's next message. When a live call is open on the conversation, the
answer is spoken in the call instead, and continued_in_call is true.
POST /v1/actions/{id}/approve (a Tier 2 "yes") answers the same way.
| locked | 423 | too many wrong PINs; locked_seconds left, and locked_until (epoch seconds) when the lock ends |
POST /v1/actions/{id}/cancel drops a pending action. Cancelling one past
its expiry answers expired (410) too; nothing changes.
POST /v1/actions/{id}/approve runs a Tier 2 action for the person who asked
it, with no PIN: executed, failed, not_allowed (403, someone else's),
needs_pin (403, a Tier 3 action), expired, already_done or not_found.
Your own assistant¶
| Route | Returns |
|---|---|
GET /v1/me/settings |
MySettings: the name you call the assistant, your wake phrase, your languages (en, hi, hinglish) and reply language (auto or one of them), each with its default |
PATCH /v1/me/settings |
MySettingsUpdate: send only what changes; null goes back to the default |
GET /v1/me/activity?limit=&before_id= |
ActivityRow[]: "What Ira did" for you, newest first, paged by id: each tool call and each action that ran, with channel, tool, device, tier, outcome and seconds. Never arguments or results |
GET /v1/info returns the name you chose. Nobody reads another person's
settings or activity through these routes.
Web and news search are off until you switch them on:
PATCH /v1/me/settings with {"web_search": true, "pin": "..."}. It is a new
grant (your questions go to the search provider), so it needs your PIN;
{"web_search": false} never does. MySettings.web_search says whether it is
on, and whether the server can answer: web_ready (a Brave Search API key in
BRAVE_SEARCH_API_KEY) and news_ready (feeds under search.news.feeds).
Without a key, web_search is not offered to the model at all: Ira says
plainly that web search is off on this server (the owner adds the key) and
uses news_search for today's news. The server makes at most 30 web searches
an hour, for everyone together.
With search on, video_find finds something to play: a named news
channel's live stream (Aaj Tak, NDTV India, ABP News, Zee News, India TV,
DD News and others, by their fixed youtube.com/@channel/live address),
otherwise YouTube's own search with a YouTube Data API key in
YOUTUBE_API_KEY, or Brave's, limited to youtube.com. It never reads
YouTube's own pages. Ira then plays the link with url_open on the
person's phone or Mac.
Grants¶
Everything you allowed Ira to use: a skill on one of your devices, or web search. Each can be switched off at once, without a PIN.
| Route | Needs | Returns |
|---|---|---|
GET /v1/me/grants |
ask |
GrantView[]: id (device-<id>-<skill> or web-<user id>), kind, skill, description, and the device |
DELETE /v1/me/grants/{id} |
a session with ask |
OkResponse; 404 if it is not one of yours |
GET /v1/admin/grants |
a session with admin.write |
everyone's, with email |
DELETE /v1/admin/grants/{id} |
a session with admin.write |
switch anyone's off; only its owner switches it on |
Switching web search writes grant.web_search to the change log, and switching
a grant off grant.revoked.
Your data¶
| Route | Needs | Returns |
|---|---|---|
GET /v1/me/export |
a session | a JSON file (ira-my-data-<day>.json): your account, settings, conversations with every message, actions you proposed, devices (never a token), events, "What Ira did", paired Telegram chats, API tokens and sessions (never a token or a hash) |
POST /v1/me/data/delete |
a session, and password again |
DataDeleted: how many conversations, messages, devices, events, activity rows and settings went |
Deleting removes your conversations and their messages, your devices (they
disconnect at once), their pairing codes and events, actions on your own
devices, "What Ira did" and your settings. Your account stays, and so do the
records a business keeps: the actions you proposed with its tools, and the
audit and change logs. An owner removes the account itself. It writes
me.data_deleted with the counts. Five wrong passwords in 15 minutes give 429.
Learning¶
Off until the person switches it on, with their PIN, and only from the
sources they choose (phone, conversation, feedback; a Mac is switched
by its routine skill). See Privacy for what is kept.
| Route | Who | Returns |
|---|---|---|
GET /v1/me/learning |
a session | LearningView: on, sources, Macs, days kept, nudges, named networks, phone tokens, counts of observations by kind, learnings and open reminders |
PATCH /v1/me/learning |
a session | LearningUpdate: on, sources, days (14-365), nudges, and pin to switch anything on |
PUT/DELETE /v1/me/networks/{fingerprint} |
a session | name a network a Mac reported by its salted hash (NetworkName), or forget it |
POST/DELETE /v1/me/learning/phone-token |
a session (PIN to create) | a token whose only scope is observe, for iPhone Shortcuts; shown once |
POST /v1/observations |
a token with observe |
ObservationIn: event (arrive, leave) and place, nothing else; recorded is false when learning or the phone source is off |
GET /v1/me/observations?days=&kind= |
ask |
your own observations of the last days (30 by default), oldest first |
POST /v1/me/learning/refresh |
a session | LearningView, after looking for patterns now (the learner also runs nightly, after 03:00 in your time zone) |
POST /v1/me/learnings/{id}/approve |
a session | LearningItem, now active: it goes into your assistant's context |
POST /v1/me/learnings/{id}/reject |
a session | LearningItem, now rejected; never asked again |
PATCH /v1/me/learnings/{id} |
a session | reword it (text); 400 if a number in it is not in its evidence |
DELETE /v1/me/learnings/{id} |
a session | LearningForgotten: it and the observations behind it are deleted |
GET /v1/me/reminders, DELETE /v1/me/reminders/{id} |
a session | your open reminders; cancel one |
POST /v1/me/feedback |
a session | FeedbackIn: message_id (from /v1/ask or the stream's done event), rating (up, down), optional note. learned is false when learning or the feedback source is off; 404 for an answer that is not yours |
A learning's text is written from its evidence (median time, middle half,
"seen on 9 of the last 15 weekdays") and every number in it is checked
against that evidence. Learnings are proposed until there is enough
evidence (8 days over at least 14, a steady time), ready then asked
once, and only active after a yes. Active ones fade after 21 days unseen,
and are asked about again when they change.
Your connections¶
Needs the connectors extra on the server (available says so) and a
session with ask.
| Route | Returns |
|---|---|
GET /v1/me/connections |
ConnectionsView: available, your connections (never a token) and the local servers this server offers |
POST /v1/me/connections |
ConnectionCreate: name, pin, and one of: url and headers or oauth: true (a remote MCP server; https and public unless the operator allows private ones), server and env (a local one), builtin: "google" (returns authorize_url), or builtin: "imap" with imap (host, user, password, port), or builtin: "telegram" with telegram (api_id, api_hash, phone; Telegram then sends a code). Returns ConnectionView |
POST /v1/me/connections/{id}/refresh |
fetches the tools again, keeping your choices |
POST /v1/me/connections/{id}/sign-in |
for a service that signs you in (OAuth, oauth: true when adding): ConnectionView with authorize_url to open; the connection finishes when the browser comes back |
POST /v1/me/connections/{id}/code |
Telegram: TelegramCode (code, and password for a two-step account); needs_password comes first when it is missing |
GET /v1/connections/oauth/callback |
public: where the service sends the browser back; the one-time OAuth state is the proof, so no session is needed |
PATCH /v1/me/connections/{id} |
ConnectionUpdate: enabled (on again needs pin), tools: {name: {enabled, tier}}; a tier below its min_tier gives 400 |
DELETE /v1/me/connections/{id} |
removes it and its token |
Each tool's min_tier comes from its annotations: read-only is 0, unmarked
or writing is 2, destructive or about sending, paying, sharing, deleting,
merging or pushing is 3. See Your connections.
Drop: your inbox across your devices¶
| Route | Who | Does |
|---|---|---|
POST /v1/me/drop |
drop (a session, a paired device, or an API token made with it) |
JSON {"text": "..."} (a link when the whole text is one address), or multipart with file and an optional text caption. 201 and the item; 413 too large (95 MB, 100,000 characters), 507 your Drop is full (2 GB) |
GET /v1/me/drop?after_id=&before_id=&limit=50 |
drop |
{"items": [...], "has_more"}, newest first; after_id gives only newer items, to catch up; before_id the next page of older ones |
GET /v1/me/drop/{id} |
drop |
one item |
GET /v1/me/drop/{id}/file |
drop |
a file's bytes, always as an attachment |
DELETE /v1/me/drop/{id} |
drop |
204; it is gone from every device |
GET /v1/me/drop/settings |
drop |
DropSettings: telegram (new items from your other devices go to your paired Telegram chat; off until you switch it on) and telegram_ready (this server forwards, and you have a paired chat) |
PATCH /v1/me/drop/settings |
drop |
{"telegram": true} to start forwarding, false to stop. Both answer with max_file_mb and max_text too |
An item: id, kind (text, link, file), text, and for a file name,
mime, size, sha256; from_device_id and from_label (the device's
name, or Web, CLI, Telegram); created_at and expires_at (30 days).
Connected devices are told at once with a {"type": "drop", "event": "new",
"item": {...}} or {"type": "drop", "event": "deleted", "id": 42} frame on
their WebSocket (not the device it came from); one that was offline catches
up with after_id. The web app hears the same frames as event: drop on
GET /v1/me/events/stream, only with the drop permission. Someone else's
item is always 404. On Telegram,
/drop <text> or a file sent with /drop as its caption puts it in your
Drop.
A token that may only ask (Siri's) has no drop, so a lost phone's
Shortcut cannot read the inbox; make one with "drop": true for the ira
CLI. The file limit is 95 MB because a Cloudflare tunnel refuses bodies over
100 MB; raise drop.max_file_mb only when nothing in front of Ira does.
A file comes back as an attachment with X-Content-Type-Options: nosniff,
never to be shown inline: an app opens it with the system's viewer or saves
it, and the web inbox previews only images it can draw safely (PNG, JPEG,
GIF, WebP) from a blob, never HTML or SVG, which could run script.
Ira has two tools of its own for it: drop_list (what is in your Drop) and
drop_send (put text or a link there, "send this to my phone"). What
drop_list returns reaches the model marked as data, like a web page: an
item may have come from Telegram or another device, so it can never ask Ira
for anything, and a turn that read it cannot propose an action. After a
turn has read text others wrote, drop_send is refused: only your own
words in a new message put something in your Drop.
Approvals¶
GET /v1/me/approvals, DELETE /v1/me/approvals[/{id}] and
PUT /v1/me/approvals/minutes: see Approvals.
A run inside a window shows in "What Ira did" with the outcome approved.
Your tokens, for Siri and shortcuts¶
| Route | Who | Does |
|---|---|---|
GET /v1/me/tokens |
a session with ask |
MyToken[]: your API tokens that still work, never their values |
POST /v1/me/tokens |
a session with ask |
MyTokenCreate (label, pin, drop): a token whose only scope is ask, plus drop when asked (for the ira CLI), shown once (TokenCreated). Your PIN, as for a new device. 10 at most |
DELETE /v1/me/tokens/{id} |
a session with ask |
stops one of your tokens |
A Siri Shortcut sends what was said to POST /v1/ask with one
(Ira on iPhone).
Your businesses¶
| Route | Who | Does |
|---|---|---|
GET /v1/me/businesses |
ask (a device too) |
BusinessView[], the latest changed first |
POST /v1/me/businesses |
a session with ask |
BusinessIn: name, and about, people, terms, may_do (600, 600, 600 and 400 characters). 409 for a name you already have |
PATCH /v1/me/businesses/{id} |
a session with ask |
BusinessChange: any of the same fields |
DELETE /v1/me/businesses/{id} |
a session with ask |
removes it |
Ira keeps them in its prompt (12 at most, 2,400 characters) and has a
Tier 1 tool, remember_business, for what the person tells it in a chat.
Reporting an answer¶
| Route | Who | Does |
|---|---|---|
POST /v1/me/reports |
ask (a session, an API token or a paired device) |
ReportIn: message_id of one of Ira's answers to you, reason (wrong, harmful, offensive, private, other) and an optional note. 404 for an answer that is not yours |
GET /v1/admin/reports?status=open |
admin.read |
ReportRow[], newest first: who, why, the note, the answer (2,000 characters) and its model |
POST /v1/admin/reports/{id}/close |
a session with admin.write |
marks it dealt with, in the change log |
The Ira app has Report under every answer; the Android and Mac apps offer the same. App stores ask every app that shows AI-written content for this.
Devices¶
The protocol, limits and security are in Devices. The routes:
| Route | Body | Returns |
|---|---|---|
POST /v1/me/devices/pairing |
PairingCreate: kind (mac, android), label, skills, pin |
PairingView: the one-time code (ABCD-EFGH, 10 minutes), server, pair_url and qr_svg (a data: URI of its QR code) |
GET /v1/me/devices/pairings/{id} |
PairingState: waiting, paired (with device_id) or expired |
|
POST /v1/devices/pair |
PairRequest: code, kind |
DevicePaired: the device's token, once; 410 for a bad code, 429 after five |
GET /v1/me/devices |
DeviceView[]: your devices, connected, the tools of their last hello and their skills (on, offered). Never a token, never output |
|
PATCH /v1/me/devices/{id} |
DeviceUpdate: label, skills (the whole list), pin (to switch one on) |
DeviceView |
DELETE /v1/me/devices/{id} |
OkResponse: the token stops working and the device disconnects |
|
GET /v1/me/skills |
SkillView[]: the skills IRA's clients offer, in words |
|
GET /v1/me/events?limit= |
EventView[]: your devices' events of the last seven days |
|
GET /v1/me/events/stream |
server-sent events, one EventView per data: line, as they happen |
|
DELETE /v1/me/events |
OkResponse |
|
GET /v1/admin/devices |
DeviceView[] for everyone, with email |
|
POST /v1/admin/devices |
PairingCreate |
PairingView: a device for yourself |
PATCH /v1/admin/devices/{id} |
DeviceUpdate |
switch someone's skills off (403 to switch one on) |
DELETE /v1/admin/devices/{id} |
revoke anyone's device |
GET /v1/downloads lists Ira.dmg (Ira for Mac), with its url on the
project's latest GitHub Release, where CI attaches it to each v* tag;
available is false, since the server does not carry it.
GET /v1/downloads/Ira.dmg redirects there. The retired ira-mac.zip and
ira-chrome-extension.zip answer 404, naming Ira for Mac instead. The app
is signed ad hoc until the project has an Apple Developer ID.
Pairing writes device.pairing_started (never the code) and device.paired
to the change log; switching skills writes device.skills, renaming
device.renamed, and revoking device.revoked. A wrong PIN counts towards
the same lock as actions: 403 with the attempts left, 423 when locked,
409 with no PIN set.
Expiry is the same everywhere. A proposal past its expires_at is
marked expired before any list, count or transcript is read (its
finished_at is then its expires_at), so it never reads as pending:
not in GET /v1/admin/actions?status=pending, not in the Overview's
actions counts or GET /v1/admin/actions/counts, and not in a
transcript's action.
Admin (read-only)¶
All need admin.read (owner, admin, viewer), from a session or a token.
| Route | Returns |
|---|---|
GET /v1/admin/overview |
Overview: today's questions (user turns stored since the start of today, every channel), model calls (one question can make several), failures, cost, tokens, latency p50/p95, per model, actions by status, active conversations, users. "Today" starts at since, the server's local midnight (epoch seconds); since_iso is the same moment with the server's offset (2026-10-12T00:00:00+05:30) and timezone the server's zone: an IANA name (Asia/Kolkata) when the system names one, else its offset (+05:30) |
GET /v1/admin/calls?limit=&before_id= |
CallRow[]: model calls, newest first, paged by id. tried is the models tried in order, joined by commas with no spaces; tried_list has them as a list |
GET /v1/admin/conversations?limit=&before=&before_id= |
ConversationRow[]: by last activity, with a preview of the first question |
GET /v1/admin/conversations/{id} |
MessageRow[]: messages, with typed tool_calls and tool results; 404 if unknown. In someone else's conversation, a tool result with private: true (a device's output) reads as a placeholder, and so does the summary of an action on their device |
GET /v1/admin/conversations/{id}/info |
ConversationRow: one conversation's header (channel, user_id, email, started_at, last_at, turns, preview), as in the list; 404 if unknown |
GET /v1/admin/actions?status=&limit=&before=&before_id= |
ActionRow[]: proposals with parameters, status, result, actor (who proposed) and decided_by / decided_at (who confirmed or cancelled) |
GET /v1/admin/actions/counts?since= |
ActionCounts: how many actions stand at each status (pending, executing, executed, failed, cancelled, expired) and total, over all time, or proposed at or after since (epoch seconds) |
GET /v1/admin/audit?limit=&before_id= |
AuditRow[]: action outcomes and PIN events with actor and channel, newest first |
GET /v1/admin/changes?limit=&before_id= |
ChangeRow[]: the console change log, newest first |
GET /v1/admin/costs?days=30 |
CostDay[]: calls, failures, tokens and cost per day (server local time) |
GET /v1/admin/tools |
ToolDetail[]: kind (read or action), JSON schema, enabled, and for actions confirm_roles |
GET /v1/admin/channels |
ChannelView[]: HTTP, web and Telegram status; Telegram says whether its token variable is set. The http channel's address (ServerAddress) has the host and port that ira serve bound (its --host and --port, else ira.yaml), configured_host and configured_port from ira.yaml, public_url (channels.http.public_url or IRA_PUBLIC_URL, else null) and url, the base URL to open: public_url when set, else http://host:port |
GET /v1/admin/models |
ModelsView: each tier's chain as configured and in use, cool-downs, providers with key status and last success or failure. cooling_reasons has, for each model in cooling, a CoolingReason: the last error's error_kind (capacity, timeout, auth, bad_request, server, network, error), its HTTP status if any, and a short message the server writes itself (never the provider's text, which can quote the prompt, and never a key). voices (VoiceOption[]: id, languages, gender) lists valid voice ids for the text-to-speech provider in use |
GET /v1/admin/keys |
KeyView[]: each secret variable ira.yaml names, who uses it, and whether it is set. Never the value |
GET /v1/admin/settings |
SettingsView: assistant name and voice, each with its ira.yaml default and whether it is overridden |
GET /v1/admin/security |
SecurityView: PIN lock, wrong PINs so far, session length, cookie SameSite, token scopes, users without a PIN, confirm roles per action |
GET /v1/admin/users |
AdminUserRow[]: accounts with has_pin (no password or PIN hashes) |
GET /v1/admin/roles |
RoleView[]: what each role may do |
GET /v1/admin/tokens |
TokenRow[]: API tokens (no token values); scopes is comma-separated |
GET /v1/admin/telegram |
TelegramView: enabled, token_env, token_present, the bot's bot_username and bot_link (https://t.me/<username>), bot_error, running, last_poll_at, last_error, pairing_seconds, and chats (TelegramChat[]: chat_id, name, paired_at, user_id, email, role). The username comes from Telegram's getMe, asked at most once an hour (a failure is retried after a minute) and never while the token variable is unset |
GET /v1/admin/users/invites |
InviteView[]: open invitations. Needs users (owner) |
GET /v1/admin/sessions?limit=&before=&before_id= |
AdminSessionRow[]: every user's active sessions, as in GET /v1/me/sessions plus user_id, email and role. Needs users (owner) |
actor is the person, channel is where. In ActionRow, AuditRow
and ChangeRow, actor is the person's email and channel is web,
api, telegram or cli; ActionRow adds decided_channel next to
decided_by. Older rows put the channel into the actor
(owner@example.com via web); reads drop that ending when it names the
row's channel, and the change log, which had no channel column, takes its
channel from it. An actor that is not an email (owner from an early CLI)
is returned as stored.
Paging¶
The lists stay JSON arrays. Every paged list answers with an
X-Has-More: true|false header: the server fetches one row more than
limit, so true means another page exists and false that this page is
the last. The header is on GET /v1/admin/calls, /v1/admin/conversations,
/v1/admin/actions, /v1/admin/changes, /v1/admin/audit,
/v1/admin/sessions, /v1/me/sessions and /v1/conversations, and is
declared on each in /v1/openapi.json.
For the next page, send the cursor the last row gives:
| List | Order | Cursor |
|---|---|---|
| calls, changes, audit | id, newest first | before_id = the last row's id |
| conversations (admin and yours) | last_at, newest first, then id |
before = the last row's last_at and before_id = its id |
| actions | created_at, newest first, then id |
before = the last row's created_at and before_id = its id |
| sessions (yours and everyone's) | last_used_at, newest first, then id |
before = the last row's last_used_at and before_id = its id |
With before and before_id together, rows that share the same moment are
neither skipped nor repeated. before alone still works as it did (only
strictly older rows) and before_id without before is ignored on these
lists. Sessions default to 100 a page (limit up to 500); a session's
last_used_at moves each time it is used, so a long walk through sessions
can see one twice.
IRA answers no cross-origin requests, so it sends no CORS headers. A proxy
that adds CORS for a console on another origin must list X-Has-More in
Access-Control-Expose-Headers, or the browser hides it.
Console API¶
The routes the admin console uses to change things. Model names in this
section (InviteCreated, SettingsView, ...) are the schemas in
/v1/openapi.json, where every field is described; operation ids are the
handler names (create_invite, test_tool, ...).
Rules for every write¶
- A session, never a token. Every write under
/v1/admin,PUT /v1/me/pinand everything under/v1/me/sessions(reads too) need a signed-in session: the cookie, or a session token (ira_s_...) as a bearer. An API token gets403whatever its scopes. - A role. Users, invitations and other people's sessions need
users(owner). Your own sessions need no particular role. Everything else needsadmin.write(owner, admin). Staff and viewers get403. - CSRF. With the cookie, send
X-Ira-Request: 1. See below. - The change log. Every successful change writes one row: who
(
actor, e.g.owner@example.com), where (channel,web), what, the target, and the value before and after where there is one. Read it atGET /v1/admin/changes. Secrets (passwords, PINs, tokens, keys) are never written to it.
Cross-site request forgery¶
A cookie-authenticated request that changes something is refused with 403
unless all of these hold:
- it carries
X-Ira-Request: 1. A page on another site cannot add a custom header without a CORS preflight, and IRA never answers one; Sec-Fetch-Site, when the browser sends it, issame-originornone;Origin, when the browser sends it, names this server: theHostheader, orX-Forwarded-Hostwhen a proxy rewritesHost.
The cookie itself is HttpOnly and SameSite=Strict. Requests with a bearer
token are not checked, because a browser never adds one on its own.
First-run setup¶
While no account has ever been made, the server holds a one-time setup
code (three groups of four hex digits) and ira serve prints a link:
When no console is built into src/ira/web/admin/, the link is
http://127.0.0.1:8765/#code=... instead, and the chat page's setup form
asks for the code. With public_url set, the link starts with it
(https://ira.example.com/admin/setup#code=...). The code rides in the URL fragment, which browsers never
send to the server or a proxy log. Whatever page receives the link reads
location.hash, then posts the code:
| Route | Body | Returns |
|---|---|---|
GET /v1/setup/status |
SetupStatus: needs_owner, name, version |
|
POST /v1/setup/owner |
SetupOwnerRequest: code, email, password (10+ characters) |
SessionResponse, and the session cookie |
POST /v1/setup/owner answers 403 for a wrong code (five wrong codes from
one address give 429 for 15 minutes), 400 for a bad email or password
(the code stays valid), and 409 once any account exists. Creating the owner
and checking that no account exists happen in one write transaction, so two
racing requests cannot both succeed. The code is then dead.
POST /v1/auth/setup (body email, password, setup_code) does the same
for older clients.
Invitations¶
An owner invites someone; the response carries the token once, and a path to open on the server:
{
"invite": {"id": "inv_3a9c...", "email": "desk@example.com", "role": "staff",
"created_by": "owner@example.com", "created_at": 1791800000.0,
"expires_at": 1792059200.0, "state": "pending"},
"token": "ira_i_...",
"path": "/admin/invite#token=ira_i_..."
}
The link is the server's origin plus path, for example
https://ira.example.com/admin/invite#token=ira_i_.... When public_url is
set (channels.http.public_url or IRA_PUBLIC_URL), the response's url
is that whole link; otherwise url is null. The owner sends it
themselves: IRA sends no email. Only the token's SHA-256 hash is stored; it
works once and expires after 72 hours, and a newer invitation for the same
email replaces an open one.
The invitation page reads the token from location.hash and uses two public
routes:
| Route | Body | Returns |
|---|---|---|
POST /v1/invites/lookup |
{"token": "ira_i_..."} |
PublicInvite: email, role, expires_at; 410 if unknown, used, withdrawn or expired |
POST /v1/invites/accept |
{"token": "ira_i_...", "password": "..."} |
SessionResponse, and the session cookie; 410 as above, 400 for a short password |
Inviting the email of a removed account brings that account back, with its history, the new password and the invited role.
Your own PIN¶
| Route | Needs | Body | Returns |
|---|---|---|---|
GET /v1/me/pin |
any credential | PinStatus (below) |
|
PUT /v1/me/pin |
session, actions |
PinSetRequest: password (your account password, checked again), pin (4-12 digits) |
OkResponse |
PinStatus:
{"has_pin": true, "can_confirm": true, "pin_set_at": 1791800000.0,
"locked_seconds": null, "locked_until": null,
"pin_min_length": 4, "pin_max_length": 12}
pin_set_at: when you last set your PIN (epoch seconds);nullwithout a PIN, and for a PIN set before IRA recorded the moment.locked_until: when the wrong-PIN lock ends (epoch seconds), next tolocked_seconds(the same moment as seconds from now); bothnullwhen actions are not locked.GET /v1/admin/securityand everyActionOutcomecarrylocked_untiltoo.pin_min_length,pin_max_length: the PIN rule, read from the one place it lives in the server (ira.security).PinSetRequestandConfirmRequestin/v1/openapi.jsonuse the same numbers.
A wrong password gives 403; five in 15 minutes give 429. Setting a PIN
here does not lift a wrong-PIN lock; ira pin set on the server does. The
outcome is written to the action audit log as pin_set.
Your password and email¶
| Route | Needs | Body | Returns |
|---|---|---|---|
PUT /v1/me/password |
a session | PasswordChange: current_password, new_password (10+ characters) |
SessionsEnded: your other sessions, signed out; this one stays |
PUT /v1/me/email |
a session | EmailChange: email, password (checked again) |
OkResponse |
A wrong password gives 403; five in 15 minutes give 429. API tokens are
not touched by a new password: revoke them on their own. Both are written to
the change log (account.password, account.email), never the password.
Your sessions¶
Each sign-in is a session. The list shows the active ones (not ended, not
expired), most recently used first, as SessionView:
{"id": "ses_9b1c...", "created_at": 1791800000.0, "last_used_at": 1791803600.0,
"expires_at": 1793009600.0, "current": true,
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) Firefox/131.0",
"device": "Firefox on macOS"}
id names the session; it is not a credential, and the session token and
its hash never appear. current marks the session making the request.
user_agent is the browser's User-Agent at sign-in, cut to 200
characters, and null when none was sent or the session started before IRA
stored it. device is a short label the server reads from it: the browser
and the system (Chrome on macOS, Safari on iPhone, Samsung Internet on
Android), a known client (curl, Python (httpx)), or the name a client
gives itself; null without a user_agent. IRA keeps no client IP
addresses for sessions.
| Route | Needs | Returns |
|---|---|---|
GET /v1/me/sessions?limit=&before=&before_id= |
a session | SessionView[]: yours, 100 a page, with X-Has-More (see Paging) |
DELETE /v1/me/sessions/{id} |
a session | OkResponse; 404 if it is not one of your active sessions. Ending the current one also clears the cookie |
POST /v1/me/sessions/revoke-others |
a session | SessionsEnded: ended, how many of your other sessions were signed out; this one stays |
GET /v1/admin/sessions?limit=&before=&before_id= |
users |
AdminSessionRow[]: everyone's, with user_id, email and role, paged the same way |
DELETE /v1/admin/sessions/{id} |
a session with users |
OkResponse; 404 if not active |
An API token cannot list or end sessions, even its own user's. The writes
follow the rules above (CSRF header with the cookie) and write
session.revoked (target: the session id, before: {"user": email}) or
session.revoked_others (target: your email, after: {"ended": n}) to the
change log. Removing a user also ends their sessions.
Every console write¶
| Route | Needs | Body | Returns | Change log what |
|---|---|---|---|---|
POST /v1/admin/users/invites |
users |
InviteCreateRequest: email, role |
InviteCreated |
user.invited |
DELETE /v1/admin/users/invites/{invite_id} |
users |
OkResponse; 404 if not open |
user.invite_withdrawn |
|
PATCH /v1/admin/users/{user_id} |
users |
RoleChange: role |
AdminUserRow; 409 for the last owner |
user.role (before, after) |
DELETE /v1/admin/users/{user_id} |
users |
OkResponse; 409 for the last owner |
user.removed |
|
POST /v1/admin/users/{user_id}/reset-pin |
users |
OkResponse |
user.pin_reset |
|
PUT /v1/admin/users/{user_id}/password |
users |
PasswordReset: new_password |
SessionsEnded: all their sessions end |
user.password_reset |
PUT /v1/admin/users/{user_id}/email |
users |
EmailSet: email |
AdminUserRow |
user.email (before and after) |
POST /v1/admin/tokens |
admin.write |
TokenCreateRequest: label, scopes |
TokenCreated (the token value, once) |
token.created |
DELETE /v1/admin/tokens/{token_id} |
admin.write |
OkResponse; 404 if not active |
token.revoked |
|
DELETE /v1/admin/sessions/{session_id} |
users |
OkResponse; 404 if not active |
session.revoked |
|
DELETE /v1/me/sessions/{session_id} |
a session | OkResponse; 404 if not yours and active |
session.revoked |
|
POST /v1/me/sessions/revoke-others |
a session | SessionsEnded |
session.revoked_others (only when one ended) |
|
PATCH /v1/admin/tools/{name} |
admin.write |
ToolSwitch: enabled |
ToolDetail |
tool.enabled (before, after) |
POST /v1/admin/tools/{name}/test |
admin.write |
ToolTestRequest: params |
ToolTestResult: ok, seconds, result or error; 409 for an action tool |
tool.tested (the params) |
POST /v1/admin/models/test |
admin.write |
ModelTestRequest: ref (provider:model from a tier) |
ModelTestResult: ok, seconds, text or error_kind and error |
model.tested |
PUT /v1/admin/models/tiers/{tier} |
admin.write |
TierOrderRequest: order (the same models, reordered; null resets) |
TierView |
model.chain (before, after) |
PATCH /v1/admin/settings |
admin.write |
SettingsUpdate: name, voice (send only what changes; null resets to ira.yaml) |
SettingsView |
settings.name, settings.voice |
POST /v1/admin/telegram/pairing |
admin.write |
TelegramPairingRequest (optional): user_id, default you; someone else's needs users |
TelegramPairing: code, command (/pair CODE), expires_at, user_id, email, bot_link; 404 for an unknown user |
telegram.pairing_started (target: the email, after: {"expires_at": ...}; never the code) |
DELETE /v1/admin/telegram/chats/{chat_id} |
admin.write |
OkResponse; 404 if that chat is not paired |
telegram.unpaired (target: the chat id, before: {"user": email, "name": ...}) |
Setup and accepting an invitation also write setup.owner and
user.joined.
Notes:
- Users. The last owner can never be removed or demoted: make someone
else owner first. Removing a user disables the account (the row stays, so
conversations and logs keep their author) and revokes their sessions,
tokens and paired Telegram chats. Resetting a PIN clears it; the user sets
a new one with
PUT /v1/me/pinand cannot confirm actions until then. - Tokens are created for the signed-in user. Scopes are from
ask,voice,actionsandadmin.read, and cannot exceed the role. Revoking is by id. - Tools. A switched-off tool is hidden from the model and from
/v1/info, and a pending action for it fails instead of running. The switch is stored in the database and survives restarts. Testing runs a read tool with the given parameters, validated like a model's call, with a 30-second limit; the result is cut at 20,000 characters. - Models. A test sends one fixed short prompt to that model alone, logs
the call with purpose
test(it costs what a call costs), and lifts the model's cool-down if it answers. A saved order must list exactly the modelsira.yamlgives the tier; ifira.yamllater changes the tier, the saved order is ignored. - Settings. The name and voice are stored on top of
ira.yamland take effect at once. The voice is the text-to-speech voice id (Polly, for exampleJoanna; OpenAI, for examplealloy); it cannot be set while text-to-speech is off (SettingValue.editableis false).GET /v1/admin/modelslists the valid ids invoices, a static list kept inira/voice/voices.py: for Polly, its neural voices for Indian English and Hindi (Kajal, who speaks both), US English and British English; for OpenAI, the nine voices every model accepts (alloytoshimmer); the demo voice forfake; nothing when text-to-speech is off. The server does not refuse other ids: Polly has more voices than the list shows. - Keys stay in the environment or
.env. The console can only see whether each variable is set. - Telegram. A pairing code from the console is the same one-time code
ira telegram pairprints: eight characters, 10 minutes, stored as a hash. The chat that sends/pair CODEacts as that user. Pairing a chat for someone else needsusers(owner): it would otherwise let an admin act as the owner on Telegram. A chat'snameis what Telegram gives (first and last name, and@username), kept at pairing and refreshed on each message; chats paired before IRA kept names shownulluntil they next write. Unpairing takes effect at once: the bot answers that chat as it answers strangers. - Testing a tool answers
200whether or not the tool worked: readok. Parameters that do not fit the tool's schema giveok: falsewitherrorstartinginvalid parameters:, as do a tool error, a crash (the tool raised ...) or no result within 30 seconds. Only a missing tool (404), an action tool (409) or a body that is not JSON (422) are HTTP errors.