HTTP API reference
Mjolnir’s daemon serves a documented HTTP API beside the web viewer. It exists so another program — an orchestrating agent, a script, a CI job — can run a session as a subagent: start it with a prompt, block until the turn ends, read a structured outcome, send the next prompt, page the transcript, and take the work out as a diff, a file, a pushed branch, or a git bundle.
The mj subcommands in the CLI reference
are thin clients for these routes. Use them when you can; use the routes
directly when your caller is not a shell.
The web viewer’s own /api/... routes are a different thing: they serve the
browser, they are cookie-only, and they change whenever the browser needs
something new. Only /api/v1/... is documented and stable.
Find the URL and the token
Section titled “Find the URL and the token”mj api-infoIt prints the base URL, the file holding the bearer token, and the contract version:
base url http://127.0.0.1:3765/api/v1token file /home/you/.local/share/mjolnir/api-tokenversion Mjolnir API 1The API is served by the web viewer’s listener, so it follows the [phone]
section of config.toml. By default that is a loopback address reachable only
from this machine. When Tailscale detection succeeds, the same routes are also
reachable over your tailnet at the https://…ts.net:PORT address the viewer
advertises. The API is not served when the viewer is disabled; mj api-info
then says so, and mj daemon status reports the viewer’s state.
Authenticate
Section titled “Authenticate”Send the token as a bearer token:
curl -sS -H "Authorization: Bearer $(cat ~/.local/share/mjolnir/api-token)" \ http://127.0.0.1:3765/api/v1/sessions- The token file is created with mode 0600 when the viewer first starts, and it survives daemon restarts.
- Delete the file and restart the daemon to rotate the token. The old token stops working immediately; nothing else changes.
- A browser already signed in to the viewer may call these routes with its viewer session cookie instead, which is how a page served by the viewer can reach them without a second secret.
Without either credential every route answers 401 with the usual error body.
Version header
Section titled “Version header”Every response, 401s included, carries:
Mj-Api-Version: 1Cache-Control: no-storeCheck the header before parsing a body. A response without it did not come from
this API; a response naming another major version speaks a contract your client
does not know. mj refuses both.
Errors
Section titled “Errors”A failure is a JSON object with an error sentence:
{ "error": "this session cannot take a prompt right now" }Some refusals also carry a code that names the reason, so a client can add
advice that fits its own interface. The sentence names no interface. Today the
only code is subagent_choice_unavailable: a new session asked for a
global task-type sub-agent model or effort that no enabled profile offers.
| Status | Meaning |
|---|---|
400 | The request was malformed: an empty prompt, a timeout outside 1–3600 seconds, a file path that is absolute or contains ... |
401 | No bearer token and no valid viewer cookie. |
404 | No such session, or no transcript recorded for it. |
409 | The session cannot do this now: no prompt capability, no live target, a turn still running, no commits to bundle, no recorded base for a diff, no push remote configured. |
500 | The operation was attempted and failed. The message says what failed. |
503 | The daemon is shutting down, or the controller is not accepting actions. |
Messages are written for you, the owner of the daemon, and they name real profiles, targets, and git errors. Treat them as diagnostics, not as strings to match on.
Routes
Section titled “Routes”Get a GitHub App installation token
Section titled “Get a GitHub App installation token”GET /api/v1/github-token?owner=acmeGET /api/v1/github-token?repo=acme/project&repo=acme/toolsThe authenticated daemon client receives a currently valid installation token
for the configured GitHub App. An owner selects its installation and returns
an installation-wide token. Repeated repo=OWNER/NAME values discover one
installation and limit the token to those repositories. The response is
{ "token": "..." }; treat it as a secret and never log or persist it. The
route returns an error when no App is configured.
The mj github-token command wraps this
route.
List workspaces
Section titled “List workspaces”GET /api/v1/workspaces{ "workspaces": [ { "id": "workspace-1", "name": "Release work", "created_at": "2026-09-11T07:00:00Z", "last_opened_at": "2026-09-17T09:12:03Z", "session_count": 2 } ]}Most recently opened first, which is the order the terminal’s workspace tabs use. A fresh instance answers with an empty list.
Create a workspace
Section titled “Create a workspace”POST /api/v1/workspaces{"name": "Release work"}{ "workspace": { "id": "workspace-1", "name": "Release work", "created_at": "2026-09-17T09:12:03Z", "last_opened_at": "2026-09-17T09:12:03Z", "session_count": 0 }}The name is the identity: it is trimmed, must be 1–64 characters with no control characters, and is unique case-insensitively. Naming a workspace that already exists returns that workspace rather than making a second one, so a script may call this before every run. An unusable name returns 400.
List sessions
Section titled “List sessions”GET /api/v1/sessionsThe list includes top-level sessions and sub-agents by default, preserving the
original API behavior. Pass ?all=false to list only top-level sessions or
?all=true to request all sessions explicitly. The CLI uses all=false by
default; mj sessions --all includes sub-agents.
{ "sessions": [ { "id": "session-1", "workspace_id": "workspace-1", "title": "add a README line", "harness_kind": "claude", "profile_id": "profile-1", "target_id": "target-1", "bundle_id": "bundle-1", "state": "Running", "lifecycle": "live", "chat_phase": "idle", "is_idle": true, "has_error": false, "created_at": "2026-09-11T07:00:00Z", "updated_at": "2026-09-11T07:04:21Z" } ]}lifecycle is one of live, starting, suspending, suspended, failed.
chat_phase is one of idle, running, closing, closed.
Session detail and the session embedded in wait responses also include optional
background_work from the current connected worker snapshot. Its known field
is true when background state is synchronized, false when it is not yet
synchronized, and null when the worker does not report this capability. tasks
contains known task records (id, started_at_ms, command, can_stop). The
whole field is omitted when no connected snapshot is available.
All session responses also include background_tasks from the daemon’s current
projection. Each record has id, command, started_at_ms, and can_stop.
Treat IDs as opaque when requesting a stop.
POST /api/v1/sessions/{session_id}/background-tasks/stopSend {"background_task_id":"<opaque task id>"}. The daemon validates that
the task is still present and stoppable, then waits for the worker’s
acknowledgement and answers 202 with no body. A task that has ended or cannot
be stopped returns 409; an unavailable worker returns 503; a provider
stop failure returns 500. Inspect the session again to confirm task exit.
A finished prompt or idle chat phase does not prove background tasks have ended.
For Kimi, a routine checkpoint requires known: true and an empty task list,
in addition to the normal checkpoint prerequisites. A deferred bundle export
returns 409 with the prerequisite that blocked it; actual checkpoint failures
return 500. Retry after state synchronizes and tasks finish. Missing worker
support requires an updated worker; do not infer safety from an empty list alone.
Get one session
Section titled “Get one session”GET /api/v1/sessions/{session_id}The same object, plus last_turn_outcome when a prompt has finished on it:
{ "id": "session-1", "last_turn_outcome": { "command_id": "api-7f3c", "accepted_ordinal": 42, "turn_start_position": 43, "completed_ordinal": 61, "completed_at_ms": 1788000000000, "outcome": { "kind": "completed", "stop_reason": "end_turn" } }}outcome.kind is completed with a stop_reason, rejected with a message,
or interrupted with a message. This field retains the raw harness evidence:
completed means the harness returned, and its stop_reason may report failure.
Use mj wait or the normalized turn_ended event outcome to determine success.
The list route omits the field: it is built from the dashboard projection,
which carries no turn identity.
List launch options
Section titled “List launch options”GET /api/v1/options{ "revision": 1790192456730219, "profiles": [{ "id": "codex", "harness": "codex" }], "targets": [ { "id": "localhost", "kind": "local-bare", "requires_project_directory": true, "availability": "ready", "host": "local" }, { "id": "docker", "kind": "local-docker", "requires_project_directory": false, "availability": "ready", "host": "local" } ], "bundles": [ { "id": "fixture", "primary_repository": "fixture", "repositories": [{ "id": "fixture", "destination": "fixture" }] } ], "hosts": [ { "id": "local", "label": "local", "targets": ["docker", "localhost"], "stale": false, "refreshing": false, "has_error": false } ], "default": { "profile_id": "codex", "target_id": "localhost" }}This route lists the profiles, targets, and bundles a new session can use,
without reading config.toml. It never fails.
revisionidentifies the configuration the lists came from. Two replies with the same revision describe the same configuration.profiles[].harnessis the harness kind, such ascodexorclaude.targets[].requires_project_directoryistruewhen the target needs an existing directory (project_directoryon create) instead of a bundle. Only SSH bare targets require that directory to be a Git project.targets[].availabilityisready(the last check passed),stale(the reading is old),unavailable(the last check failed), orunknown(no check yet, normal just after startup). A local Podman, Docker, or Apple container target is alsounavailablewhen its engine is missing or does not answer, asmj doctorand the dashboard report it. Anunavailabletarget may carryunavailable_reason, a sentence for a person.bundles[].repositories[].githubappears only for a repository with a GitHub source.hostshas one entry per host that has reported capacity. It is omitted when empty.defaultis the profile and target a create request uses when it names none. It is omitted when no default has been saved.
The reply contains no credentials, harness homes, SSH hosts or keys, container environments, AWS details, or controller paths.
Create a session
Section titled “Create a session”POST /api/v1/sessions{ "workspace_id": "workspace-1", "profile_id": "codex", "target_id": "localhost", "bundle_id": "bundle-1", "project_directory": "/home/you/project", "branch": "main", "base": "origin/main", "title": "add a README line", "model": "<model-id-from-profile-config>", "effort": "high", "prompt": "add a README line"}profile_id and target_id may be omitted; each then follows the saved
default that GET /api/v1/options reports. Supply bundle_id, or
project_directory, or both: a directory with no bundle is bundled the way the
viewer’s own form does it.
Sessions started through this route do not change the saved default, the
remembered sub-agent choice, or what a new-session form preselects next. Only
interactive launches (the dashboard, mj go, mj simple, and the web or phone
form) remember choices. mj acp, sub-agents and archive restores do not.
When model is supplied without profile_id, Mjolnir checks every enabled,
usable profile and chooses the one offering the exact model and, when named,
the exact effort. It ranks by quota headroom paced against each window’s reset,
using the saved default profile to break ties. Usage-priced (API key) profiles
come after every subscription profile that has or may have quota left. If no configured profile
supports that selection, the request fails without changing the requested
model or effort. Supplying both
profile_id and model pins the profile and validates the model and effort on
it.
Four fields choose where the session starts:
at: a full commit object ID to start the workspace at. Before the first prompt, the bundle’s primary repository is checked out at that commit. HEAD is detached unlessbranchis given. Requiresbundle_id.branch: withat, the name of a new local branch created at that commit. Withoutat, the existing branch checked out in an isolated clone; otherwise the remote default is used.base: the Git revisionmj diffcompares against, when it is notat. It defaults toat. It does not move the checkout. A bundle session resolves it in the fresh clone, so name a commit SHA, a tag, ororigin/<branch>. For a raw project’s managed worktree withoutat, the worktree also starts here.no_later_history: withat, give the workspace onlyat,baseand their ancestors. See Start without later history.
To start a bundle session at an exact commit on a new private branch:
{ "workspace_id": "workspace-1", "profile_id": "codex", "target_id": "builder", "bundle_id": "product", "at": "0123456789abcdef0123456789abcdef01234567", "branch": "town/run-123"}at always applies to the bundle’s primary repository (its configured
primary_repo). Other repositories in the bundle start at their remote
defaults. at must be a full commit object ID, not a branch, tag, abbreviated
SHA, or revision expression. Mjolnir fetches the exact object from origin if
needed and refuses an unavailable commit. A moving remote branch cannot change
the selected revision.
With at, branch creates a new local branch without an upstream. Preparation
never pushes or modifies the source checkout. Existing branches, dirty
workspaces, and invalid selections fail visibly. at cannot be combined with
project_directory alone or with create_managed_worktree: false.
Start without later history
Section titled “Start without later history”Set no_later_history: true with at when the agent must not see commits made
after at, for example to review a pull request as it stood:
{ "workspace_id": "workspace-1", "profile_id": "codex", "target_id": "builder", "bundle_id": "product", "at": "0123456789abcdef0123456789abcdef01234567", "base": "89abcdef0123456789abcdef0123456789abcdef", "branch": "review/pr-670", "no_later_history": true}The workspace is not a clone. Mjolnir runs git init, fetches at and base
by object ID, which brings their whole history and nothing later, and checks
out at. The workspace then holds:
- the full history up to
atandbase: both commits and every ancestor commit, tree and blob, with their real object IDs. It is not shallow.baseneed not be an ancestor ofat; it is kept as the refrefs/mj/base. - the
branchyou named, or a detached HEAD. - no
originremote, remote-tracking branches, tags, or other refs.
On container targets with a GitHub repository, the clone cache does not mount its mirror. It mounts a Git bundle that holds only the same history, and the workspace fetches from it. Elsewhere, the workspace fetches from the network.
Requirements:
atmust be given, andbase, if given, must be a full commit object ID. There are no remote branches or tags to resolve a name against.- The bundle must have one repository. Others would start with their whole history.
- The session cannot be resumed or moved, because both clone the repository again with its full history. Start a new session instead.
What the agent can still reach: this limits the Git history in the workspace.
It is not a network sandbox. The agent can still run git fetch <url>, call
the GitHub API or gh, or read the web, any of which can show later work. To
give the agent one later revision on purpose, ask it to run
git fetch --no-tags <url> <sha>. That fetches only the commit and its
ancestors. Directories you attach with additional_mounts are not checked.
subagents sets the session’s delegation policy:
{"subagents": {"mode": "mjolnir"}}Choose native for harness delegation or mjolnir for Mjolnir-managed children.
Omitted policy defaults to Native, or to none for a DSH profile, which refuses
native because DSH has no sub-agents of its own. Mjolnir requires a Claude, Codex, DSH, or OpenCode parent and
a straightforward choice, either global or a session override. A subtle choice
is optional:
{"subagents": {"mode": "mjolnir", "subtle": {"model": "gpt-6-luna", "effort": "max"}}}straightforward and subtle independently override those task choices; an
omitted type uses its global choice. A session may override one type and use
global configuration for the other. Omit effort to use the harness default;
adaptive is invalid. The selected model and effort must be available to the
enabled profiles.
An unavailable choice answers 422 with code subagent_choice_unavailable.
Move can explicitly change the policy; omission retains it. Legacy none
policies and child sessions remain readable. Per-profile defaults are ignored.
oracle (boolean) and oracle_choice ({"model": "...", "effort": "..."}) set
the session’s read-only oracle on Create and Move, independently of subagents.
On Create an omitted oracle uses the choice last made for the profile, and an
oracle_choice implies true. On Move, omission retains the session’s values,
and oracle: false keeps the override. An explicit request with no oracle model
configured or given creates or moves the session with the oracle off and a
notice.
POST /api/v1/sessions/{session_id}/subagents starts a child with task_name
and instructions. Optional task_type is straightforward or subtle;
omission uses Straightforward for older callers. subtle fails with 409 when
the session has no subtle choice. working_directory is
optional. The daemon resolves the session override or global task choice and
selects an enabled profile by quota. Spawn no longer accepts profile_id, model, or
effort overrides. Without a session override, if global choices become unconfigured, spawn fails
with a message naming the missing configuration section and matching CLI flag.
review sets the session’s review mode, overriding [review] for this session:
{"review": {"mode": "push"}}{"review": {"mode": "off"}}review.mode accepts off or push; turn is refused. Optional reviewers
sets reviewer choices. off disables review for the session. When review is
omitted, the session follows global reviewer settings. See
per-session settings.
no_project_memory (boolean, default false) starts the session with no
project memory: nothing from the workspace’s project memory is added to the
agent’s context, the harness is offered no memory directory, and the session’s
sub-agents get none either. The mj-memory history tools are not offered
either. The choice is stored with the session and kept on resume and Move.
no_github_events (boolean, default false) prevents the GitHub watcher from
registering the session as a delivery target. Sub-agents inherit the flag. This
is the flag’s only effect. Global [github.watch] enabled and [jev] enabled settings control GitHub
event production.
On a container target, the session gets the same CPU and memory limit that
the viewer’s create form selects: the size last chosen for that host, else
4 CPUs and 8 GiB. New containers need at least 2 CPUs and 4 GiB. cpus and
memory_bytes override either one:
{"remember_container_size": true, "cpus": 16, "memory_bytes": 68719476736}A size above the host’s reported totals answers 400, and so does either
field on a target that is not a container target. CPU overcommit is admitted
with a warning. RAM overcommit is also admitted for Docker and Podman,
including on macOS. Only Apple container targets refuse aggregate RAM
overcommit or an unavailable or incomplete RAM budget: their VMs do not
return freed memory to macOS. Create, resume, and move share this policy.
A creation warning is returned in the optional notice field. remember_container_size (boolean, default false) saves the selected
size as the default for that physical host without changing the saved profile,
target, recent-launch, or sub-agent defaults.
Legacy all_models and single_model policy values are read as mjolnir;
legacy model and effort selectors are ignored. This also applies to recorded
policies on resume and on a Move with no policy override.
Session creation still returns its ID before preparation finishes. Wait for
readiness or inspect the session’s failure before prompting. Readiness guarantees
that preparation verified the selected commit, branch, and clean working tree.
Session receipts report the immutable starting selection as at, branch and
base, the same names the request uses, alongside the session and bundle IDs.
base reads as at when no other base was named. A session started without
later history also reports no_later_history: true. While provisioning, these
record intent, not completed work.
After prompting or resuming a checkpoint, the current checkout can differ: use
the diff endpoint’s repository metadata to inspect it. Resume preserves saved
work instead of resetting to the original selection.
Reuse the same bundle and workspace IDs for successive dispatches; each dispatch gets its own session checkout. Interrupted preparation retains its session and failure outcome. Retrying preparation may continue only the same selection in an unchanged checkout; it refuses to overwrite work. Cleanup follows ordinary session suspension/destruction and ACP exit policy. Repeating HTTP creation makes a new session rather than retrying the previous one.
Everything else is optional. idempotency_key is no
longer accepted: a request that still carries it is rejected as an unknown
field.
{ "session_id": "session-1", "turn_id": null }The reply is 201 as soon as the controller has published an id.
turn_id is null on creation: provisioning a target takes minutes, so the
first prompt is submitted in the background once the harness is ready, after
model and effort are applied. A wait on the session without a turn_id
does not answer before that first turn has ended, and then waits for the
session to settle.
Send a prompt
Section titled “Send a prompt”POST /api/v1/sessions/{session_id}/prompt{ "text": "now add a test" }{ "turn_id": 57 }The reply is 202: the prompt was accepted, not finished. turn_id is the
relay acceptance ordinal, and it is what wait takes. A session that is still
starting holds the prompt for up to 60 seconds and accepts it once its worker
attaches. An interrupt-turn sent during that hold withdraws the prompt: it
answers 409 and never becomes a turn. Any other session that cannot take a
prompt right now answers 409.
For recovery operations, supply an optional command_id (also exposed as
mj prompt --command-id ID and mj message --command-id ID). Retrying the
identical command with the same ID
returns its original relay acceptance ordinal. Keep the body unchanged; use a
new ID for a different command. Ordinary clients may omit it.
Send a session message
Section titled “Send a session message”POST /api/v1/sessions/{session_id}/message{ "request_id": "message-1", "text": "Please check the retry path.", "sender_session_id": "sender-session-1"}sender_session_id is optional. The CLI sets it from MJ_SESSION_ID when
present.
Without sender_session_id the message is from the user, and the daemon
submits it as the user’s prompt, exactly as POST /sessions/{id}/prompt does:
a session that is still starting holds it, a busy session takes it into the
running turn or queues it, and the same refusals apply. request_id is the
prompt’s command ID, so retrying with the same request_id returns the same
turn rather than sending a second prompt. {session_id} may be a full ID or a
unique hexadecimal prefix of at least 8 characters. The reply is 202 with the
full session_id, via: "turn", and the turn_id that wait takes.
With sender_session_id, the daemon requires the sender to be an existing
top-level session. An authenticated local caller may claim another top-level
session ID. The reply is 202 with session_id, via (mailbox or turn),
and an optional turn_id. Message delivery refuses an unknown, destroyed,
stopped, self, or other session’s sub-agent target. A peer mailbox message
wakes an idle recipient; workers below relay protocol 36 receive a queued turn
with the same sender label and reply instruction.
Typed /clear clears the native conversation in the same session and checkout.
Its acceptance is not completion: observe the command_ended event and the
context divider in the transcript before sending the new task. A rejected or
failed clear does not authorize resetting the checkout.
Cancel queued prompts
Section titled “Cancel queued prompts”POST /api/v1/sessions/{session_id}/queued-prompts/clear{ "command_id": "cancel-obsolete-input-1" }The ID is optional. This returns 202 with the relay acceptance turn_id,
cancels queued input, and preserves the active turn and environment. The CLI
is mj clear-queue --session ID [--command-id ID] --json. Interrupt an active
turn separately when needed.
Wait for a turn
Section titled “Wait for a turn”POST /api/v1/sessions/{session_id}/wait{ "turn_id": 57, "timeout_secs": 600 }Both fields are optional. timeout_secs defaults to 600 and may not exceed
3600. The two forms answer different questions.
- With
turn_id, the wait answers when that turn has ended, and says how. The turn has ended once the worker has stopped working on that prompt: it keeps waiting while the worker is about to resend the same request (a server retry, which answers for the turn when it ends), while Jev is still reading the reply, and while a sub-agent child still owes its report. It does not wait for anything that starts new work: a turn the agent starts on its own, background work, an expected continuation, Mjolnir’s automatic continuation, a later prompt, or a goal. To learn when your prompt’s turn ended, pass itsturn_id, or usemj prompt. - Without
turn_id, the wait answers when the session settles: the worker’s activity state is idle (the same state the dashboard shows as idle), nothing is queued, the session can take a prompt, and a first prompt handed over at creation has ended. That includes turns the agent starts on its own, so this form can wait long after your prompt’s turn ended. It then reports how the latest turn ended; a session with no turn at all answersfinishedat once.
A session keeps only its latest turn outcome, so a wait on turn 57 that answers
late can describe turn 61; turn_id says which turn the answer is about, and
requested_turn_id repeats the one you asked for. Queued prompts are why the
explicit id matters: without it, an earlier prompt’s outcome could answer for a
later one.
wait reports how the named turn ended, and nothing else. An error the
session recorded for some earlier, unrelated action does not fail the turn you
asked about; the wait keeps waiting until that turn actually ends.
When the session is not idle as the wait answers, next carries the worker’s
activity state, the same object as activity_state on a session, for example
"next": { "state": "background", "started_at_ms": 1788000000000 } after a turn
that left a build running. It is absent when the session is idle.
{ "outcome": "finished", "stop_reason": "end_turn", "final_message": "I added the line and ran the tests.", "turn_id": 57, "turn_number": 3, "elapsed_ms": 42318, "tool_calls": 6, "relay": { "state": "connected" }, "session": { "id": "session-1" }}For mj wait --json and mj prompt --json, stdout contains the JSON
response even when the command exits unsuccessfully. finished and
input_required exit 0. timeout, error, cancelled, quota_limit, and
stopped exit 1, with a diagnostic on stderr. A timeout does not cancel the
turn. Transport or authentication failures may have no JSON response.
Preserve stdout before interpreting the exit status, for example:
result = subprocess.run( ["mj", "wait", "--session", session_id, "--json"], capture_output=True, text=True, check=False,)if not result.stdout.strip(): raise RuntimeError(result.stderr)response = json.loads(result.stdout)# Handle response["outcome"], including timeout, before deciding whether to retry.outcome | What happened |
|---|---|
finished | The turn completed normally. |
error | The turn failed or was rejected, the session could not be launched or started, or the harness gave a stop reason Mjolnir does not recognize. stop_reason and message say which. |
cancelled | The turn was cancelled or interrupted, which is what interrupt-turn produces. |
quota_limit | Reported usage quota is exhausted, or Jev read the reply as a quota stop. When a quota recovery is recorded for the turn, message is its notice, whether or not a resume is scheduled: the resume is a new turn. |
timeout | The deadline passed with the turn still running. Wait again with the same turn_id. |
stopped | The session is suspended or failed, so no turn can finish on it. message carries the session’s own recorded reason when it has one, such as a resume that failed. |
turn_numberis the one-based position of this turn in the conversation, andelapsed_msis how long it took from its first item to its last change. Both are absent when the wait ended without a finished turn.tool_callsis how many tool calls the turn made, absent in the same case.final_messageis the agent’s last message of the turn, flattened to text.diagnostic, when available, preserves the provider failure’smessage, optionalcode,http_status, and provider-suppliedreset_at. The same explanation appears inmessage; session detail exposeslast_turn_diagnostic. Diagnostic details persist with the turn and its completion event.- Usage quota exhaustion does not schedule a retry. A recognized Kimi usage
limit returns
quota_limitwith stop reasonQuotaLimit. The caller decides when to send another prompt. HTTP 403 or an authentication error alone is not enough to classify a quota limit. Reset text is not an invented retry deadline. - A server retry keeps the wait waiting. When the provider refuses a turn
and Jev judges the failure transient, the worker arms its own retry of that
turn and submits it; answering then would make your next prompt collide with
the retry. The wait answers when the retry ends, with the retry’s outcome and
turn_id. If the deadline passes first, the response carries"capacity_retry": { "attempt": 2, "retry_at_ms": 1788000000000 }. Do not send a prompt while that field is present. relaydescribes the daemon’s live view of this session, when a live actor holds it:{ "state": "unreachable", "detail": "ssh: connection refused" }.stateisconnected,disconnected(attaching, or between tries),unreachable,target_missing, orprojection_integrity;detailis the view’s own description of the problem. It is on every wait response, and it never changes the outcome — it is how atimeouttells “the turn is still working” from “the daemon cannot see the worker”. A session with no live actor carries norelayfield rather than an invented reading.
Read the transcript
Section titled “Read the transcript”GET /api/v1/sessions/{session_id}/transcript?after_seq=0&limit=200{ "session_id": "session-1", "latest_seq": 61, "execution": { "state": "idle" }, "items": [ { "stable_id": "item-12", "position": 43, "seq": 43, "role": "user", "text": "add a README line", "created_at_ms": 1788000000000, "last_changed_at_ms": 1788000000000, "body": { } } ]}Page by seq, not by position: an agent message that is still streaming keeps
its position but takes a new seq each time its content grows, so passing the
highest seq you have seen as the next after_seq gives you the updated item
again rather than missing it. A page whose last seq equals latest_seq is up
to date. limit defaults to 200 and is clamped to 1000.
role is a stable word — user, agent, thought, tool, terminal,
plan, plan_proposal, or system — and text is the
item flattened the way the other surfaces flatten it. body is the raw stored
item, shaped by the harness and the ACP traffic behind it. body is not
stable: read it when you need structure the text cannot carry, and expect its
shape to change between Mjolnir versions.
The transcript is read from the durable projection, so it answers the same way while the session runs and long after it was suspended.
Read earlier messages
Section titled “Read earlier messages”GET /api/v1/sessions/{session_id}/history returns up to 128 stored items in
chronological order as { "items": [{ "role": "agent", "text": "..." }], "before": { "position": 123, "stable_id": "agent:123" }, "frontier": 456 }.
The first request returns the newest page. For the preceding page, pass both
before_position and before_id from before. A null before means the
beginning of the conversation. The cursor is exclusive and uses the original
message position, so new messages and streaming revisions do not shift pages.
The browser’s Earlier messages reader and the terminal’s Ctrl+PgUp reader use this durable history. Live views retain recent complete turns; checkpoints still contain the complete conversation.
Suspend, destroy, or interrupt a turn
Section titled “Suspend, destroy, or interrupt a turn”POST /api/v1/sessions/{session_id}/suspendPOST /api/v1/sessions/{session_id}/destroyPOST /api/v1/sessions/{session_id}/interrupt-turnThese endpoints answer 202 when accepted. Destroy and interrupt turn answer
with no content; suspend answers with the body below. Acceptance is not
completion: inspect the session’s lifecycle and error, or use mj wait for
suspension. An idle conversation alone says nothing about lifecycle completion.
Suspend saves a verified recovery copy before releasing the environment. Once that copy is verified, it stops the session’s active Mjolnir sub-agents, without a recovery copy of their own, and suspends the session alone. A suspend that fails before then leaves the sub-agents running. A sub-agent that has not handed back its report loses the work it has not reported. The answer says how many sub-agents the suspend stops and warns about those:
{ "session_id": "0123abcd…", "stopped_subagents": 2, "subagents_not_handed_back": 1, "warning": "1 sub-agent has not handed back; suspending stops it"}warning is left out when every stopped sub-agent had handed back. The
acknowledge_active_subagents field that older clients send is accepted and
ignored. For an independent clone whose publication is unverified, supply
{"acknowledge_unpublished_work": true} after reviewing the warning. A failed
suspension reports its error and preserves recoverable resources; it never
silently switches to destruction.
Destroy permanently removes the environment, recovery archive, and session
record, including sub-agents, after indexing their conversations in SessionWiki.
The optional {"delete_branch": true} body applies
only to older linked-worktree sessions and deletes their managed source branch.
New managed clones have no source branch to delete. Once destruction completes,
GET /sessions/{id} returns 404. The indexed conversation remains available
through the wiki routes and mj sessions --session <id>; restoring it starts
a new session from conversation context, not from the deleted recovery archive.
Interrupt turn keeps the environment and session available for further prompts.
For a session that is still starting, it withdraws the prompts held for it.
There is no /close endpoint or force parameter. Destruction is available only
through its dedicated authenticated endpoint, not the generic viewer actions.
Push review
Section titled “Push review”POST /api/v1/sessions/{session_id}/reviewGET /api/v1/sessions/{session_id}/reviewPOST starts a review of the session’s committed work. It answers 202 once
the review has opened. Tracked changes must be committed first. A request that
cannot start answers 409 with the reason in error.
GET answers 200 with the open review, or "review": null when none is open.
A verdict appears when available. An unknown session answers 404.
Resume a suspended session
Section titled “Resume a suspended session”POST /api/v1/sessions/{session_id}/resume{ "profile_id": "codex", "target_id": "localhost", "workspace_id": "workspace-1", "queue": "start"}The body is optional and so is every field in it: the session’s own record
supplies the profile, target, and workspace it last ran with, so a bodiless POST
means “continue this session where it left off”. queue is start or
discard, and decides what happens to prompts that were queued when the session
suspended; it defaults to start.
{ "session_id": "session-1", "workspace_id": "workspace-1", "profile_id": "codex", "target_id": "localhost"}The reply is 202 with the settings the resume will actually use. Like
creation, it answers before the session is up: restoring a checkpoint onto a
fresh target takes minutes. Wait on the session with no turn_id and the wait
blocks while the resume runs, then returns once the session is live; a resume
that fails answers stopped with the reason.
This is the same operation the terminal’s Resume wizard runs, with the same
repository preflight and the same cross-harness handoff. A running session is
refused with 409 — suspend it first, or use a move to change where a live
session runs. A session with no checkpoint to restore, such as one whose launch
failed before its first checkpoint, is refused with 409 too; remove it with
mj destroy. Model and effort are not part of this request: apply them with
PATCH /sessions/{id}/config once the wait returns.
Get the work out
Section titled “Get the work out”GET /api/v1/sessions/{session_id}/diffGET /api/v1/sessions/{session_id}/files?path=src/main.rsPOST /api/v1/sessions/{session_id}/exportdiff answers text/x-diff — a unified diff of everything the session changed
against the commit it started from, committed and uncommitted work alike,
including files the agent never told git about.
Add ?base=<revision> to choose an explicit comparison commit without changing
the recorded launch baseline. Git revisions such as HEAD~2 are accepted;
unknown revisions return 409. Add json=true to receive application/json
with diff, base, head, and head_descends_from_base fields. base and
head are resolved commit IDs; the patch still includes uncommitted and
untracked files. head_descends_from_base is false when the session’s HEAD is
no longer a descendant of the base — the agent rebased, amended, or reset — so
the diff carries history changes as well as session work; a worker from before
this field omits it. For example,
GET /api/v1/sessions/{session_id}/diff?base=HEAD~2&json=true reports both the
chosen baseline and the current checked-out commit. JSON metadata requires an
updated session worker; older workers return an explicit upgrade refusal.
files reads a file or a directory on the session’s target. A file answers
application/octet-stream with its bytes; a directory answers
application/zip with an archive of everything below it, in which symlinks are
stored as links rather than followed. With list=true it answers
application/json instead: { "path", "entries": [...] }, where each entry
has name, kind (file, directory, symlink, or other), size in
bytes, modified as RFC 3339 in UTC or null, and target for a symlink. A
directory lists its entries in name order; a file lists itself. Either way the
x-mj-fetched-path header names where the path resolved on the target,
form-urlencoded because a path need not be valid header text.
A relative path resolves in the directory the session’s agent runs in — the
primary repository’s directory, whatever the target kind — so a path the agent
would use means the same here. A .. reaches a sibling repository of a
multi-repo bundle, as in ../other/src/main.rs, and stops at the workspace root
those repositories share. A session with a single repository has no sibling to
reach, so there .. stops at the agent’s own directory. An absolute path is
read as the target spells it, so a session can read evidence another session
cites, such as a sub-agent’s report or a probe in /tmp.
After symlinks are resolved, the path must lie in that boundary, in the
directory holding the session’s sub-agent report directories, or in the
target’s temporary directory. An empty path is 400. A path outside those
directories is 409, as is a path that does not exist, a file over 16 MiB, a
directory whose archive would pass 16 MiB or whose files hold more than
128 MiB, a listing over 16 MiB, and a worker installed before directories
could be fetched; resuming the session upgrades the worker.
export takes the form you want:
{ "kind": "patch" }{ "kind": "branch", "branch": "review/one" }{ "kind": "bundle" }patchanswers the sametext/x-diffbody asdiff.branchpushes the session’s current HEAD to the repository’s push remote (remote.pushDefault, elseorigin) and answers{ "branch": "review/one", "remote": "origin" }.bundleanswersapplication/octet-streamwith aContent-Disposition: attachment; filename="<session>-<repo>.bundle"header. The bytes are a git bundlegit bundle verifyaccepts.
Preconditions, all answering 409 with the reason:
| Export | Needs |
|---|---|
diff, files, branch | A live target. A suspended session has none; use the bundle. |
branch | An idle session — a push mid-turn would publish a tree the agent is still changing — a valid branch name, and a configured push remote. |
diff | A recorded base commit, or a session branch whose reflog still names where it started. |
bundle | Commits beyond the session base. A live session is checkpointed first; a suspended one is read from its last checkpoint, so this export also works after the target is gone. |
Change what the dashboard shows
Section titled “Change what the dashboard shows”POST /api/v1/ui/requests{"from_session_id": "3f2a…", "action": {"kind": "stage_settings", "edits": [{"path": ["notify", "bell"], "value": false}]}}{"id": 4, "dashboards": 1}Every attached terminal dashboard applies the request as if the person had
pressed the keys. action.kind is one of the following:
pane_size, withpane(sessions,targetsorprofiles) andsize(minimized,standardormaximized).show_session, withsession_idandplacement(main,rightorbelow).stage_settings, withedits, a list of Settings paths and values.
Staged settings open Settings with the changes filled in but not saved; only
the person’s Save writes them, so this route never changes the configuration.
from_session_id is optional and names the session whose agent asked, for the
dashboard’s notice. The daemon checks the whole request before publishing it:
an unknown session, setting or value returns 400 with the reason, and
nothing is shown. With no dashboard attached it returns 409. dashboards
counts the dashboards that were attached to receive the request. The web
viewer does not act on these requests.
CLI equivalents
Section titled “CLI equivalents”| Command | Route |
|---|---|
mj api-info | — (prints the base URL and token path) |
mj workspaces list | GET /workspaces |
mj workspaces create <name> | POST /workspaces |
mj sessions | GET /sessions |
mj sessions --session <id> | GET /sessions/{id} |
mj new | POST /sessions |
mj prompt --session <id> <text> | POST /sessions/{id}/prompt, then POST /sessions/{id}/wait on the turn it returned |
mj message --session <id> <text> | POST /sessions/{id}/message (from your own shell, with no sender_session_id) |
mj wait --session <id> | POST /sessions/{id}/wait |
mj transcript --session <id> | GET /sessions/{id}/transcript |
mj diff --session <id> | GET /sessions/{id}/diff |
mj export --session <id> --kind patch|branch|bundle | POST /sessions/{id}/export |
mj fetch --session <id> --path <path> [--ls] | GET /sessions/{id}/files?path=[&list=true] |
mj suspend --session <id> | POST /sessions/{id}/suspend |
mj resume --session <id> | POST /sessions/{id}/resume |
mj destroy --session <id> [--delete-branch] | POST /sessions/{id}/destroy |
mj interrupt-turn --session <id> | POST /sessions/{id}/interrupt-turn |
mj review start --session <id> | POST /sessions/{id}/review |
mj review status --session <id> | GET /sessions/{id}/review |
mj stop-task --session <id> <task-id> | POST /sessions/{id}/background-tasks/stop |
mj ui pane|show|settings … | POST /ui/requests |
These commands accept --json. mj stop-task prints its aggregate report of
accepted, skipped, and failed task IDs; the other commands print the route’s
response unchanged.
A whole run
Section titled “A whole run”mj workspaces create scriptsmj new --workspace scripts --profile codex --target localhost --project-directory . \ "add a README line"mj wait --session <id>mj prompt --session <id> "now add a test"mj diff --session <id>mj export --session <id> --kind bundle --out work.bundlemj suspend --session <id> --acknowledge-unpublished-workmj transcript --session <id> still answers after suspension: the projection
and session record are retained. See session lifecycle for what suspending and
resuming do, and security boundaries for what the token reaches.
Discover and change model settings
Section titled “Discover and change model settings”GET /api/v1/profiles/{profile_id}/config returns model, models, efforts,
and observed_at (Unix seconds). Each choice contains its exact value, display
name, and optional description. Add ?model=<value> to discover effort
choices for that model. The list contains only what the profile’s models
allow-list lets through (its current model is always listed); add
?unrestricted=true for everything the harness advertises. An empty cache is populated automatically with a
prompt-free harness probe; no project session or container is created. The
first lookup may install the managed harness on the controller and take several
minutes. Discovery currently requires a Unix controller with the harness’s
installation prerequisites and access to the profile’s provider.
The cache persists across daemon restarts, expires after 24 hours, and is keyed
by profile settings and harness installation version. Efforts are cached per
model. Concurrent cold lookups share discovery. Probe failures remain retryable
and return 503 with their cause. Live managed workers with a matching build
refresh their model-specific entries. Container harnesses are still authoritative
about the settings they actually accept.
POST /sessions automatically discovers and validates requested model and
effort before bundling or provisioning. An unknown selector returns 400 with
available values, after refreshing cached choices. With an omitted profile_id,
the daemon uses the saved default profile as the eligibility anchor and ranks
profiles that offer the requested selection by quota headroom paced against
window reset times, with usage-priced (API key) profiles after every
subscription profile that has or may have quota left. Settings are applied
model first, then effort using the model’s updated choices. A later target-side
configuration failure leaves the session available for repair and is reported
by wait. It does not consume a prompt turn.
Session responses include config_options with each setting’s key, label,
current, and choices. Apply one setting with:
PATCH /api/v1/sessions/{session_id}/configContent-Type: application/json
{"key":"model","value":"kimi-code/k3"}The route waits for the setting to be applied and returns the updated session.
Invalid advertised choices return 400; a rejected configuration command returns
409 with its reason. Initialization must finish before callers change settings
or send another prompt. After repairing a failed initialization, submit a new
prompt explicitly; the original first prompt is not replayed.
mj models --profile kimi --jsonmj models --profile kimi --model kimi-code/k3 --jsonmj set-config --session "$id" --key model --value kimi-code/k3 --jsonmj set-config --session "$id" --key effort --value high --jsonWorkspace selection and stopping provisioning
Section titled “Workspace selection and stopping provisioning”GET /api/v1/sessions?workspace_id=<id> filters the session list. The CLI resolves
mj sessions --workspace <name> to that workspace ID. Without a selector, the
list includes all workspaces.
POST /api/v1/sessions accepts workspace_id. When the request names none, it
uses the instance’s only workspace when there is exactly one. An instance with
no workspace answers 409 and says how to create one, and an instance with
several needs an explicit workspace_id. No session is placed in a workspace
that the dashboard and the viewer do not list. The CLI’s mj new always names
its workspace. GET /api/v1/workspaces and POST /api/v1/workspaces
are the routes for choosing deliberately.
close is accepted while provisioning or another lifecycle operation is in
flight. It cancels cancellable work, prevents the initial prompt, waits for the
old operation to release ownership, and then cleans up. Repeated closes join the
same operation. A suspending session’s wait returns stopped, including when an
earlier initialization failed. Cleanup errors remain visible in session state. A
close whose worker restart fails leaves the session in error with the failure
recorded in its state. Retry suspension after resolving the error. Use mj destroy only when you intend to discard the environment and recovery copy.
The CLI and mj api-info probe API support before reading the token file. A
daemon predating this API produces an explicit mj daemon restart instruction;
no restart is performed automatically. Disabled viewers, connection failures,
incompatible API versions, and missing tokens have separate diagnostics.
Usage and filtered transcripts
Section titled “Usage and filtered transcripts”GET /api/v1/sessions/{id}/usage?after_seq=0&limit=200 returns recorded
turns, next_after_seq, latest_seq, totals, and coverage. Resume with
next_after_seq. Each turn includes command identity, completion sequence,
outcome, and optional usage. Wait responses also include that turn’s usage
when reported. Records persist after suspension and daemon restart. History starts
with events projected by this version; older usage is not backfilled.
Usage scope is turn, last_request, or unspecified. The managed Claude
adapter reports the whole turn. Managed Codex adapter 1.11.2 reports consumption
across a prompt’s model requests, including cancellation. Unknown resumed baselines,
missing reports, or counter resets retain incomplete (unspecified) coverage. Older
Codex adapters and historical reports retain last_request scope. Grok’s native
prompt-ledger metadata and matching completion notifications report whole-turn
consumption; explicitly incomplete reports retain unspecified scope. Kimi
reports retain unspecified scope. The managed Muse adapter 0.4.3 and
later report whole-turn usage on the prompt response and omit the report when the
backend reported no model legs; older Muse adapters produce no token reports.
Only known whole-turn reports contribute to totals. Each counter includes
tokens and reported_turns; coverage counts recorded turns, full reports, partial
last-request reports, unspecified reports, and missing reports. An absent counter
stays absent. These are reported totals for covered turns, not a billing estimate.
Grok and Muse usage may also carry provider_details: optional model_calls,
api_duration_ms, provider elapsed_ms, cost, and a model_usage map keyed by
the provider’s exact model IDs (for example, grok-4.6-build). Muse reports
model_calls, api_duration_ms, and
model_usage, and no cost: the Muse plan is subscription-metered, so a turn
carries no price. Each model row uses the same normalized token fields. Full
input already includes cache reads and cache creation; output already includes reasoning. Do not add those subsets to
input/output again, or add model rows to the top-level turn total.
Turn cost carries exact integer usd_ticks (10¹⁰ ticks per USD), its exact
usd decimal string, and is_partial. Partial cost is a reported subtotal,
not a complete bill. Missing cost means unknown, not free. Provider elapsed time
is separate from Mjolnir’s top-level wait elapsed_ms. Duplicate response and
notification reports are reconciled by native prompt identity, not summed.
These details are collected for future turns; historical missing usage remains
missing. Upgrade the controller before workers: new workers require relay
protocol 13 readers to preserve provider details when verifying event hashes. New controllers can still read older workers and
records.
provider_session_cost, when present, is the latest provider-reported cumulative
session amount, currency, and observation timestamp. It is not summed across
provider session resets. Context-window occupancy is not consumed tokens.
GET /api/v1/sessions/{id}/transcript?role=agent&role=user&after_seq=0&limit=200
filters the selected roles before applying the limit. Send role once for one
role or repeat it to select several. Roles are user, agent, thought,
tool, terminal, plan, plan_proposal, and system. Omit role for all
items; that API default is unchanged. Use next_after_seq to resume, including
empty filtered pages. When no more matching items remain, the cursor advances
across excluded roles to latest_seq. The requested limit is soft when several
items share an event sequence: all tied items travel together so paging cannot
skip them. Streaming updates are still returned when their sequence advances.
Add finished_only=true to request only agent items whose body.streaming is
false; this opt-in does not change the default response. It implies
role=agent, so omitting role or supplying only role=agent works, while
including any other role returns 400 Bad Request. In this mode,
next_after_seq is an exclusive resume cursor for the finished prefix: it
never passes the sequence of the earliest currently open agent item. When the
page limit is reached before that barrier, the cursor is the last returned
sequence; otherwise it advances through filtered non-agent rows to just before
the barrier, or to latest_seq when no agent item is open. Pass that exact
cursor on the next poll; the open item remains eligible and is returned when
its stream closes. latest_seq remains the newest sequence across all roles
and is not a resume cursor for this mode.
Closed means body.streaming was false in the projection read. ACP can still
append late chunks to the same stable_id afterward, advancing that item’s
sequence. A consumer that deduplicates only by stable_id may ignore that late
revision; finished-only polling does not promise delivery of post-close chunks.
mj usage --session SESSION --jsonmj transcript --session SESSION --role agent --limit 20 --jsonFollow events and current activity
Section titled “Follow events and current activity”GET /api/v1/events streams server-sent events using the same authentication and
version headers as the other v1 routes. Optional session_id and workspace_id
filters apply together. Workspace filtering uses the session’s current workspace.
Without a cursor, the stream follows new durable events from the current database
frontier. Use after_seq=0 to replay all retained durable history, or resume after the
last delivered sequence with after_seq=N or the Last-Event-ID: N header.
Conflicting cursors, negative or out-of-range values, and cursors ahead of this
database return 400. Sequence IDs are global; gaps in a filtered stream are normal.
Each durable SSE frame has an id equal to its sequence and an event matching
the JSON type. For example:
id: 42event: input_resolveddata: {"seq":42,"session_id":"SESSION","recorded_at_ms":1789200000000,"type":"input_resolved","data":{"elicitation_id":"REQUEST","turn_id":12,"action":"accept"}}| Event | data |
|---|---|
turn_started | turn: prompt command ID, accepted_ordinal (the API turn ID, when known), transcript start position, and start timestamp. |
turn_ended | turn: command and turn identity, completion ordinal and timestamp, outcome, and optional reported usage. |
command_ended | Command identity, owner, kind, structured outcome, and optional cause and correlation. |
session_fault | Typed reason, message, and nullable command_id for a session-level failure. |
legacy_notice | original_type, message, and nullable command_id for unclassified historical diagnostics. |
input_required | Nullable turn_id; request contains the normalized ACP elicitation and response schema for structured input. It is absent when Jev ends a turn with awaiting_input. |
input_resolved | elicitation_id, nullable turn_id, and action; cleared means the request disappeared without an explicit response observation. |
activity_changed | Transient current state: activity contains the UI’s state, nullable details, is_idle, waiting_for_input, and capacity_retry. These frames have no SSE id. |
Terminal events describe the object that ended. A failed turn_ended is the
signal that an accepted prompt failed. A failed command_ended describes its
control or shell operation; it does not establish a failed agent turn.
session_fault records a session-level problem, not a retrospective change to
completed turns. These events do not prescribe retries or automatic pauses.
turn.outcome.kind is completed, input_required, cancelled, rejected,
interrupted, or failed. Unsuccessful outcomes include reason and message;
stop_reason preserves the original harness value. Provider diagnostics and
usage remain on turn. Quota exhaustion is failed with reason quota_limit.
Unknown harness endings are failed with reason unrecognized_stop_reason.
Events, mj wait, and transcript rendering share this classification; wait
keeps its own exit codes.
command_ended includes owner (worker or daemon), command_id,
command_kind, and outcome (succeeded, cancelled, rejected, or failed).
Unsuccessful results include reason and message. A successful cancellation
command has outcome succeeded; its target has outcome cancelled.
Daemon checkpoint results can include related_command_ids for their worker
barriers. A checkpoint succeeds only after its verified archive and metadata
are durable. Barrier release alone is not proof of checkpoint success.
For example, controller disconnection releases a worker checkpoint barrier:
{"type":"command_ended","data":{"owner":"worker","command_id":"opaque-id","command_kind":"begin_checkpoint","outcome":"cancelled","reason":"controller_disconnected","message":"Checkpoint barrier cancelled because its controller disconnected"}}Other reasons include owner_lost_on_restart, requested_cancellation,
admission_rejected, command_failed, worker_restarted, runtime_stopped,
runtime_failure, nonzero_exit, signaled, timed_out, startup_failed,
lifecycle_failed, runtime_unavailable, checkpoint_failed,
checkpoint_deferred, controller_restarted, and provider_failure.
Reasons are structured facts; diagnostic wording and command-ID prefixes are
not classification contracts.
This contract replaces new generic error events. Upgrade preserves historical
sequence numbers and timestamps, normalizes retained turn results, and exposes
old generic errors as legacy_notice with original_type: "error". It does not
guess their domain from messages or IDs. Older workers without typed terminal
causes can also produce legacy notices until their normal idle upgrade. Missing
historical turn causes are legacy_unclassified, never assumed harmless.
The new stored event variants require a forward database migration; older
incompatible executables cannot access that upgraded store.
Prompt starts, completions, and explicit input transitions are journaled with
their relay projection transaction, including intermediate transitions within one
projection batch. Activity is delivered from the current UI snapshot after
durable catch-up, on every connection, and whenever it changes. Brief intermediate
activity may coalesce. Activity frames have no SSE id; their JSON seq is the
durable position reached by this stream, not a unique activity event ID. Multiple
activity updates may share that position. Do not deduplicate activity by seq.
Only the latest activity per session is stored, without appending activity history.
Historical activity_changed rows are omitted from replay and removed in bounded
background batches. Cleanup preserves lifecycle events and sequence frontiers;
freed pages are reusable by SQLite without compacting the database file.
Activity details.kind is turn, step, background, idle, or lifecycle,
with available start/idle timestamps and a label. Unknown activity has no details.
Idle uses the existing UI classification: no foreground or background work.
Elapsed silence does not produce a separate stalled state. Pending input and
capacity retries remain separate facts.
While a turn or a tool call is in flight, details.last_activity_at_ms reports
when anything last arrived from the harness. Subtract it from the current time
to get the silence age. Mjolnir publishes this and does not act on it: a turn
waiting on a long build is silent and healthy, and ending a turn on a guess
destroys real work. mj wait includes the silence age in its timeout
message, and mj sessions --session prints it, so an orchestrator can decide
whether to keep waiting or call mj interrupt-turn. The field is absent for an
idle session, for a session the daemon currently cannot see, and from workers
too old to report the ACP clock.
Durable history survives session stop and daemon restart and is deleted when its
session is forgotten. Recording begins with this version; old transitions are not
backfilled. Consumers should save the last processed ID and deduplicate replayed
IDs after reconnecting. Activity instead resynchronizes from the current snapshot
on reconnect and does not promise replay of intermediate states. Slow readers are
buffered within fixed limits and cannot block session execution. Keepalive
comments carry no application event. A storage
failure may send an unnumbered stream_error frame and end the stream; reconnect
from the last processed ID. The viewer’s existing /api/events route is separate.
mj events --session SESSION --after-seq 0mj events --workspace-id WORKSPACE_ID --after-seq 42mj --workspace WORKSPACE_NAME eventsmj events prints one JSON event per line. Omit the cursor to follow new events
only; press Ctrl-C to stop. After an interrupted connection, restart with the last
printed seq as --after-seq.
Upload files and answer structured questions
Section titled “Upload files and answer structured questions”PUT /api/v1/sessions/{id}/files?path=input.json accepts a raw binary
body up to 16 MiB and returns { "path": "input.json", "bytes": 123 }.
The path is relative to the agent’s directory, as with file reads, so an
upload lands where a read of the same path finds it, and a .. stops at the
same boundary. Parent directories are created. Absolute paths, paths past the
boundary, and symlink paths are refused. Existing files require
overwrite=true; publication is atomic.
The worker verifies the expected byte count before publication, so interrupted
transfers cannot publish truncated files.
The session must have a live idle worker, with no initialization, queued work, or active background work. The controller holds a worker barrier through the transfer, then releases it without making a checkpoint. Transfers have a five-minute deadline. Cancelling the request signals the subprocess to stop; the supervised transfer keeps its ownership until the subprocess exits. A complete file published before cancellation remains in place. An older installed worker must be upgraded by resuming the session before this command is available.
GET /api/v1/sessions/{id}/elicitations lists pending structured input requests.
They also appear as pending_elicitations in session detail. Respond with
POST /api/v1/sessions/{id}/elicitations/{request_id} and one of:
{"action":"accept","content":{"name":"example"}}{"action":"decline"}{"action":"cancel"}Free-text answers use an ACP form with a string field, such as the name field
above. Choice questions and plan approvals use the schema supplied by the harness;
inspect the pending request before constructing the response. These explicit
requests emit input_required, and their resolution emits input_resolved on
the event stream. An assistant question written only in chat text is not an ACP
elicitation. When Jev confidently identifies such a question during a quiet turn,
an input_required event without request precedes turn_ended; answer with a
new prompt instead of the elicitation response endpoint.
The response is checked against the actual request, including required fields and allowed choices, before dispatch. Successful dispatch returns 202; an unknown request returns 404 and an invalid answer returns 400.
Add "return_on_input": true to a wait request to return input_required with
pending_elicitations when a structured answer is needed. This does not mean
the turn finished. Send the response, then wait again. Ordinary waits retain
their existing behavior for structured requests. A turn completed with
awaiting_input returns input_required even without return_on_input; its
pending_elicitations may be empty.
mj put-file --session SESSION --path project/input.json ./input.jsonmj put-file --session SESSION --path project/input.json --overwrite ./input.jsonmj wait --session SESSION --return-on-input --jsonmj elicitations --session SESSION --jsonmj respond --session SESSION --elicitation REQUEST --response-file answer.jsonmj put-file accepts - as the source for binary stdin. mj respond accepts a
positional JSON response or - for stdin. mj prompt also accepts
--return-on-input. The CLI exits successfully on input_required
results so an orchestrator can answer and resume waiting.
Container upgrades
Section titled “Container upgrades”GET /api/v1/sessions/{id}/container-upgrade returns the inspected current image,
selected target image, immutable identities when cached, retained mount paths and
writable-layer warning. Unsupported preservation layouts return an error without
stopping the worker.
POST /api/v1/sessions/{id}/container-upgrade accepts
{"selection": <the preview>, "acknowledge_writable_layer_loss": true}. The daemon
rechecks the selection, persists the request and returns 202. A busy worker
finishes its accepted work before replacement; waiting requests survive daemon
replacement. DELETE at the same path cancels a pending request before
replacement begins. Existing lifecycle status reports progress and cancellation.
POST /api/v1/sessions/{id}/restart performs a normal agent restart inside the
existing container, preserving its filesystem. Container replacement requires the
separate upgrade operation and its acknowledgment.