Skip to content

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.

Terminal window
mj api-info

It prints the base URL, the file holding the bearer token, and the contract version:

base url http://127.0.0.1:3765/api/v1
token file /home/you/.local/share/mjolnir/api-token
version Mjolnir API 1

The 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.

Send the token as a bearer token:

Terminal window
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.

Every response, 401s included, carries:

Mj-Api-Version: 1
Cache-Control: no-store

Check 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.

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.

StatusMeaning
400The request was malformed: an empty prompt, a timeout outside 1–3600 seconds, a file path that is absolute or contains ...
401No bearer token and no valid viewer cookie.
404No such session, or no transcript recorded for it.
409The 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.
500The operation was attempted and failed. The message says what failed.
503The 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.

GET /api/v1/github-token?owner=acme
GET /api/v1/github-token?repo=acme/project&repo=acme/tools

The 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.

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.

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.

GET /api/v1/sessions

The 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/stop

Send {"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 /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.

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.

  • revision identifies the configuration the lists came from. Two replies with the same revision describe the same configuration.
  • profiles[].harness is the harness kind, such as codex or claude.
  • targets[].requires_project_directory is true when the target needs an existing directory (project_directory on create) instead of a bundle. Only SSH bare targets require that directory to be a Git project.
  • targets[].availability is ready (the last check passed), stale (the reading is old), unavailable (the last check failed), or unknown (no check yet, normal just after startup). A local Podman, Docker, or Apple container target is also unavailable when its engine is missing or does not answer, as mj doctor and the dashboard report it. An unavailable target may carry unavailable_reason, a sentence for a person.
  • bundles[].repositories[].github appears only for a repository with a GitHub source.
  • hosts has one entry per host that has reported capacity. It is omitted when empty.
  • default is 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.

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 unless branch is given. Requires bundle_id.
  • branch: with at, the name of a new local branch created at that commit. Without at, the existing branch checked out in an isolated clone; otherwise the remote default is used.
  • base: the Git revision mj diff compares against, when it is not at. It defaults to at. It does not move the checkout. A bundle session resolves it in the fresh clone, so name a commit SHA, a tag, or origin/<branch>. For a raw project’s managed worktree without at, the worktree also starts here.
  • no_later_history: with at, give the workspace only at, base and 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.

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 at and base: both commits and every ancestor commit, tree and blob, with their real object IDs. It is not shallow. base need not be an ancestor of at; it is kept as the ref refs/mj/base.
  • the branch you named, or a detached HEAD.
  • no origin remote, 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:

  • at must be given, and base, 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.

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.

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.

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.

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 its turn_id, or use mj 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 answers finished at 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.
outcomeWhat happened
finishedThe turn completed normally.
errorThe 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.
cancelledThe turn was cancelled or interrupted, which is what interrupt-turn produces.
quota_limitReported 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.
timeoutThe deadline passed with the turn still running. Wait again with the same turn_id.
stoppedThe 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_number is the one-based position of this turn in the conversation, and elapsed_ms is how long it took from its first item to its last change. Both are absent when the wait ended without a finished turn. tool_calls is how many tool calls the turn made, absent in the same case.
  • final_message is the agent’s last message of the turn, flattened to text.
  • diagnostic, when available, preserves the provider failure’s message, optional code, http_status, and provider-supplied reset_at. The same explanation appears in message; session detail exposes last_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_limit with stop reason QuotaLimit. 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.
  • relay describes the daemon’s live view of this session, when a live actor holds it: { "state": "unreachable", "detail": "ssh: connection refused" }. state is connected, disconnected (attaching, or between tries), unreachable, target_missing, or projection_integrity; detail is the view’s own description of the problem. It is on every wait response, and it never changes the outcome — it is how a timeout tells “the turn is still working” from “the daemon cannot see the worker”. A session with no live actor carries no relay field rather than an invented reading.
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.

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.

POST /api/v1/sessions/{session_id}/suspend
POST /api/v1/sessions/{session_id}/destroy
POST /api/v1/sessions/{session_id}/interrupt-turn

These 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.

POST /api/v1/sessions/{session_id}/review
GET /api/v1/sessions/{session_id}/review

POST 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.

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 /api/v1/sessions/{session_id}/diff
GET /api/v1/sessions/{session_id}/files?path=src/main.rs
POST /api/v1/sessions/{session_id}/export

diff 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" }
  • patch answers the same text/x-diff body as diff.
  • branch pushes the session’s current HEAD to the repository’s push remote (remote.pushDefault, else origin) and answers { "branch": "review/one", "remote": "origin" }.
  • bundle answers application/octet-stream with a Content-Disposition: attachment; filename="<session>-<repo>.bundle" header. The bytes are a git bundle git bundle verify accepts.

Preconditions, all answering 409 with the reason:

ExportNeeds
diff, files, branchA live target. A suspended session has none; use the bundle.
branchAn idle session — a push mid-turn would publish a tree the agent is still changing — a valid branch name, and a configured push remote.
diffA recorded base commit, or a session branch whose reflog still names where it started.
bundleCommits 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.
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, with pane (sessions, targets or profiles) and size (minimized, standard or maximized).
  • show_session, with session_id and placement (main, right or below).
  • stage_settings, with edits, 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.

CommandRoute
mj api-info— (prints the base URL and token path)
mj workspaces listGET /workspaces
mj workspaces create <name>POST /workspaces
mj sessionsGET /sessions
mj sessions --session <id>GET /sessions/{id}
mj newPOST /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|bundlePOST /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.

Terminal window
mj workspaces create scripts
mj 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.bundle
mj suspend --session <id> --acknowledge-unpublished-work

mj 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.

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}/config
Content-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.

Terminal window
mj models --profile kimi --json
mj models --profile kimi --model kimi-code/k3 --json
mj set-config --session "$id" --key model --value kimi-code/k3 --json
mj set-config --session "$id" --key effort --value high --json

Workspace 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.

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.

Terminal window
mj usage --session SESSION --json
mj transcript --session SESSION --role agent --limit 20 --json

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: 42
event: input_resolved
data: {"seq":42,"session_id":"SESSION","recorded_at_ms":1789200000000,"type":"input_resolved","data":{"elicitation_id":"REQUEST","turn_id":12,"action":"accept"}}
Eventdata
turn_startedturn: prompt command ID, accepted_ordinal (the API turn ID, when known), transcript start position, and start timestamp.
turn_endedturn: command and turn identity, completion ordinal and timestamp, outcome, and optional reported usage.
command_endedCommand identity, owner, kind, structured outcome, and optional cause and correlation.
session_faultTyped reason, message, and nullable command_id for a session-level failure.
legacy_noticeoriginal_type, message, and nullable command_id for unclassified historical diagnostics.
input_requiredNullable 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_resolvedelicitation_id, nullable turn_id, and action; cleared means the request disappeared without an explicit response observation.
activity_changedTransient 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.

Terminal window
mj events --session SESSION --after-seq 0
mj events --workspace-id WORKSPACE_ID --after-seq 42
mj --workspace WORKSPACE_NAME events

mj 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.

Terminal window
mj put-file --session SESSION --path project/input.json ./input.json
mj put-file --session SESSION --path project/input.json --overwrite ./input.json
mj wait --session SESSION --return-on-input --json
mj elicitations --session SESSION --json
mj respond --session SESSION --elicitation REQUEST --response-file answer.json

mj 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.

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.