Skip to content

HTTP API: episodes

The execution episodes this shared server holds: one per client that asked for one, opened when the client attaches, kept alive by its own traffic, detached rather than closed when the connection goes, and closed on a deliberate detach, on shutdown, or by the reaper when nothing came back. The episode boundary for a client with no provider hooks of its own (ADR 0103).

Rendered from docs/generated/openapi.json, which the executable generates from its capability registry and CI refuses stale. The raw document is at /openapi.json; a running server serves the same document at its own /openapi.json and Swagger UI at /swagger.

episodes

The execution episodes this shared server holds: one per client that asked for one, opened when the client attaches, kept alive by its own traffic, detached rather than closed when the connection goes, and closed on a deliberate detach, on shutdown, or by the reaper when nothing came back. The episode boundary for a client with no provider hooks of its own (ADR 0103).

GET /api/v1/episodes episodes.list

List episodes

Every execution episode this shared server holds, open and detached, with the connection holding each, when its client last spoke, how many connections have carried it, and what the repository's own episode command reported. In-memory: what survives the process is the repository's session record.

  • query
  • behaviorally_verified
  • benchmark required
  • cache disabled
  • MCP tool majordomus_episodes

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/episodes"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 EpisodeList List episodes: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
POST /api/v1/episodes/attach episodes.attach

Attach this connection to an episode

Open an execution episode for the calling client, or resume the one it already had. 'external_id' is the client's own durable name for the sitting — its conversation or thread id — and must survive a reconnect: a client that comes back under the same identity is given its own episode again, under whatever peer id it now has, rather than a second one. This server's peer id will not do; it is handed out per connection. After this the client's own traffic keeps the episode alive, losing the connection detaches rather than closes it, and it is closed by majordomus_session_detach, by the server stopping, or by the reaper once the reattach grace has passed. The repository's episode is opened by the same command a provider hook runs, and what it reported is in the answer. Needs an MCP session: over plain HTTP there is no connection to bind to.

  • command
  • behaviorally_verified
  • benchmark required
  • cache disabled
  • MCP tool majordomus_session_attach

Request body — AttachInput, JSON

example default

{"external_id":"benchmark-episode"}

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s -X POST "http://127.0.0.1:8741/api/v1/episodes/attach" -H "content-type: application/json" -d '{"external_id":"benchmark-episode"}'

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 Attached Attach this connection to an episode: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
422 ErrorBody refused: The command was understood and turned down for the reason the message gives.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
POST /api/v1/episodes/detach episodes.detach

Close this connection's episode

Close an execution episode deliberately: the work is done, not merely interrupted, and the repository's record says so. Without 'external_id' it is whichever episode the calling connection holds, which needs an MCP session: over plain HTTP there is no connection, so the call is refused and the episode must be named. A client that simply goes away does not need this — its episode detaches and the reaper closes it as interrupted — and the difference between those two records is the one thing about an ended episode that changes what somebody does next.

  • command
  • behaviorally_verified
  • benchmark required
  • cache disabled
  • MCP tool majordomus_session_detach

Request body — DetachInput, JSON

example default

{}

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s -X POST "http://127.0.0.1:8741/api/v1/episodes/detach" -H "content-type: application/json" -d '{}'

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 Detached Close this connection's episode: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
422 ErrorBody refused: The command was understood and turned down for the reason the message gives.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.

Schemas

The components only this tag's operations use. Each row is the type's own field, its doc comment the description; a type another page owns links there.

AttachInput

object
The input of episodes.attach: which piece of work this connection is carrying.

The identity is the only field, and an unknown one is refused rather than ignored, so a client that sends a peer id under another name learns it at once:

use majordomus_cli::capability::builtin::episodes::AttachInput;
let input: AttachInput = serde_json::from_str(r#"{"external_id":"conv-42"}"#).unwrap();
assert_eq!(input.external_id, "conv-42");
assert!(serde_json::from_str::<AttachInput>(r#"{"external_id":"x","peer":"p1"}"#).is_err());
assert!(serde_json::from_str::<AttachInput>("{}").is_err(), "the identity is required");
propertytyperequireddescription
external_id string yes The client's own durable name for this episode — its conversation id, thread id, or whatever it calls the sitting it is in.

It must survive a reconnect, because that is the whole of its job: a client that comes back under the same identity is given its own episode again rather than a second one. Anything the client can reproduce will do; what will not do is this server's peer id, which is handed out per connection and is a different string every time the client reconnects.

Used by episodes.attach

Attached

object
The answer of episodes.attach: the episode the connection now holds, whether it was resumed rather than opened, and how long it outlives a lost connection.

use majordomus_cli::capability::builtin::episodes::Attached;
let schema = serde_json::to_value(schemars::schema_for!(Attached)).unwrap();
for field in ["episode", "resumed", "reattach_grace_seconds"] {
    assert!(schema["required"].as_array().unwrap().iter().any(|f| f == field), "{field}");
}
propertytyperequireddescription
episode ConnectionEpisode → ConnectionEpisode yes The episode this connection now holds.
resumed boolean yes Whether an episode already under this identity was resumed rather than a new one opened. True after a reconnect, a client restart or a crash, and the field a caller checks to know whether it is continuing something.
reattach_grace_seconds integer (uint64) yes How long this episode survives its connection before the reaper closes it, in seconds. Stated rather than left to be discovered: a client that knows the grace can decide whether reconnecting is worth it.

Used by episodes.attach

ConnectionEpisode

object
One episode: what the client calls itself, which connection holds it now, and what the repository did about it.

use majordomus_cli::episodes::{ConnectionEpisode, EpisodeBoard};
use majordomus_cli::peers::{PeerBoard, Transport};
let peer = PeerBoard::new().attach(Transport::Http);
let (episode, _) = EpisodeBoard::new().attach(&peer, "conv-42", "generic");
let episode: ConnectionEpisode = episode;
assert_eq!((episode.external_id.as_str(), episode.provider.as_str()), ("conv-42", "generic"));
assert_eq!(episode.peer.as_ref(), Some(&peer));
// never empty: a board with no repository behind it says so
assert!(episode.repository.contains("without a driver"));
propertytyperequireddescription
external_id string yes The client's own durable name for this piece of work, and the key everything here is addressed by. Never a peer id: see the module documentation.
provider string yes The provider id this episode is recorded under, as share/providers.yaml names one.
peer PeerId | null → PeerId no The connection holding it, when one does. None while detached — which is a state an episode is supposed to reach and come back from, not a fault.
opened_at string yes When it was first opened, RFC 3339, UTC. Unchanged by a re-attach: the episode is the work, and the work did not restart because a socket did.
last_activity_seconds_ago integer (uint64) yes Seconds since the client last spoke on it, through any connection.
attachments integer (uint32) yes How many times a connection has taken it over, the first attach included.
state ConnectionEpisodeState → ConnectionEpisodeState yes Open, or detached and waiting for its client.
repository string yes What the repository's own episode command reported, verbatim — the record it opened, or the reason it could not. Never interpreted here, and never empty: a subsystem that silently does nothing is the failure mode this field exists to make visible.

Used by Attached Detached EpisodeList

ConnectionEpisodeState

"open" | "detached"
Where an episode stands: held by a live connection, or waiting for its client to come back. There is no closed state, because a closed episode leaves the board; what survives it is the repository's own record.

use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
use majordomus_cli::peers::{PeerBoard, Transport};
let peer = PeerBoard::new().attach(Transport::Stdio);
let board = EpisodeBoard::new();
assert_eq!(board.attach(&peer, "w", "generic").0.state, ConnectionEpisodeState::Open);
assert_eq!(board.detach(&peer)[0].state, ConnectionEpisodeState::Detached);
assert_eq!(serde_json::to_string(&ConnectionEpisodeState::Detached).unwrap(), "\"detached\"");
  • openA connection holds it and the client is speaking.
  • detachedThe connection has gone; the episode is kept for REATTACH_GRACE so the client can come back to it. This is the state a crash lands in, and the one a reconnect recovers from.

Used by ConnectionEpisode

DetachInput

object
The input of episodes.detach: the episode to close.

Empty is a valid input, meaning the episode the calling connection holds; over an interface with no connection that input is refused, since there is nothing to take "this one's episode" from.

use majordomus_cli::capability::builtin::episodes::DetachInput;
let mine: DetachInput = serde_json::from_str("{}").unwrap();
assert!(mine.external_id.is_none());
let named: DetachInput = serde_json::from_str(r#"{"external_id":"conv-42"}"#).unwrap();
assert_eq!(named.external_id.as_deref(), Some("conv-42"));
assert_eq!(serde_json::to_string(&mine).unwrap(), "{}", "an omitted id is not written as null");
propertytyperequireddescription
external_id string | null no The episode to close. Omitted, it is whichever one this connection holds, which is what a client ending its own sitting means.

Used by episodes.detach

Detached

object
The answer of episodes.detach: the episode as it was closed, no longer held by any connection, with what the repository's end event reported.

use majordomus_cli::capability::builtin::episodes::Detached;
let schema = serde_json::to_value(schemars::schema_for!(Detached)).unwrap();
assert_eq!(schema["required"], serde_json::json!(["episode"]));
propertytyperequireddescription
episode ConnectionEpisode → ConnectionEpisode yes The episode as it was closed, its repository field carrying what the repository's own end event reported.

Used by episodes.detach

EpisodeList

object
The answer of episodes.list: every episode this server holds, counted by state, and the one the caller holds. In memory only — it is the association between live connections and the repository's records, which stops being true when the process stops.

The two counts partition the list, and mine is left out of the JSON rather than written as null when the caller holds nothing, which is always so over plain HTTP:

use majordomus_cli::capability::builtin::episodes::EpisodeList;
let none = EpisodeList { open: 0, detached: 0, episodes: vec![], mine: None };
let json = serde_json::to_value(&none).unwrap();
assert_eq!(json, serde_json::json!({"open": 0, "detached": 0, "episodes": []}));
let back: EpisodeList = serde_json::from_value(json).unwrap();
assert_eq!(back.open + back.detached, back.episodes.len());
propertytyperequireddescription
open integer (uint) yes How many are open — held by a connection that is still there.
detached integer (uint) yes How many are detached, waiting for a client to come back inside the grace. Not a fault count: this is where a crashed client's work waits to be resumed.
episodes array of ConnectionEpisode → ConnectionEpisode yes Every episode, open and detached, in external-identity order.
mine ConnectionEpisode | null → ConnectionEpisode no The episode the calling connection holds, when it holds one.

Used by episodes.list

Searches the titles and descriptions of every page this site publishes.