Skip to main content

MQTT Control API

The Kerrigan MQTT control API lets a cloud caller send asynchronous commands to one Kerrigan system and track each command independently. Commands and events are UTF-8 JSON objects transported through AWS IoT Core with MQTT QoS 1 and retain disabled. Kerrigan limits each published control event to 120 KiB; an oversized event is not published.

For appliance, fleet, and heartbeat reports sent from Kerrigan to the cloud, see the MQTT State Reporting API.

The operation contracts describe Kerrigan capabilities available to integrating services. The transport and topic names below describe the currently shipped AWS IoT adapter; the vulcan/ command prefix is an existing wire contract, not a requirement to use a particular cloud application. Other transports require an adapter that supplies the same command envelope to the dispatcher.

Topic contract​

Replace {environment}, {cluster_id}, and {command_id} with resolved values. The cluster ID is the immutable AWS IoT Thing name, not the display name of the Kerrigan system.

DirectionTopicPurpose
Caller → Kerriganvulcan/{environment}/kerrigan/clusters/{cluster_id}/commandsSubmit commands.
Kerrigan → callerskerrigan/{environment}/clusters/{cluster_id}/eventsAggregate event stream for the system.
Kerrigan → callerkerrigan/{environment}/clusters/{cluster_id}/commands/{command_id}/eventsEvents for exactly one command. Recommended for request/response use.

Subscribe to the command-specific event topic before publishing the command. The CLI discovers these topics from the AWS IoT registry and latest system status; direct integrations must resolve the system and construct the same topics.

Submit a command​

Publish one JSON object to the system's command topic:

{
"schema_version": 1,
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"correlation_id": "request-18a75fe1",
"requester_id": "cloud:operator@example.com",
"operation": "devices.boot",
"issued_at": "2026-09-02T22:40:00Z",
"arguments": {
"target_ids": ["modalix-123"]
}
}

Command fields​

FieldTypeRequiredDescription
schema_versionintegeryesMust be 1.
command_idstringyesGlobally unique, stable ID assigned by the caller. Use the same value when retrying the same logical command.
correlation_idstringyesCaller-owned request, trace, or workflow ID returned unchanged in every event.
requester_idstringyesIdentity of the user or service requesting the operation.
operationstringyesOne of the supported operation names below.
issued_atRFC 3339 stringrecommendedTime at which the caller created the command.
argumentsobjectnoOperation arguments. Defaults to an empty object.

command_id provides execution idempotency and event lookup. correlation_id is metadata for the caller and does not deduplicate commands. Generate a new command ID for every new logical operation, even when another command has the same target and arguments.

Operations​

kerrigan.ping​

Tests command transport and dispatcher availability.

{"arguments": {}}

The completed event contains:

{
"result": {
"status": "ok",
"server_time": "2026-09-02T22:40:00.123456+00:00"
}
}

kerrigan.update​

Starts a Kerrigan host update and streams the privileged host updater's state changes and command logs until the update reaches a terminal state.

{
"arguments": {
"system_id": "ll2-b887d76b-8989-461d-b42b-8d5b1e93de9a",
"channel": "develop",
"version": "latest",
"force": false
}
}

system_id and channel are required. system_id must be the immutable cluster ID in the MQTT command topic; Kerrigan rejects a mismatch before starting the update. version defaults to latest. Kerrigan rejects the operation while firmware jobs are active unless force is true. When supplied, force must be a JSON Boolean; strings such as "false" are rejected.

Each new host-updater log record is published as command.progress. Its progress_percent mirrors the updater, message contains the human-readable message plus command stdout/stderr when present, and the original record is in detail.host_update. A successful update ends with command.completed; failed, rolled-back, and recovery-required updates end with command.failed. Because applying a release recreates Fleet Manager, Kerrigan persists the command and resumes the same command-specific event stream after reconnecting to MQTT. Sequence numbers continue from the last persisted event.

kerrigan.reboot​

Schedules a normal reboot of the Kerrigan controller host, not its DevKits. Send this operation to the existing cluster-scoped command topic:

{
"schema_version": 1,
"command_id": "cmd-controller-reboot-unique-id",
"correlation_id": "request-unique-id",
"requester_id": "backoffice:operator@example.com",
"operation": "kerrigan.reboot",
"arguments": {"system_id": "<immutable-cluster-id>"}
}

The only accepted argument is system_id, which must exactly match the connected MQTT profile's immutable cluster ID. Device selectors, force overrides, and custom shell commands are not accepted. Existing IoT command-topic permissions control access; the requester field is audit metadata, not authentication.

The same admission checks as the dashboard block active firmware, bootstrap, and device operations. The host updater blocks concurrent host updates or power requests. Both controller software and host runtime must support power control. An unavailable or incompatible host runtime produces command.failed.

command.completed means the host scheduled the reboot for one minute later. Its result contains system_id, status (including operation_id, state, operation, and delay_seconds), and an explanatory message. It does not prove that the reboot occurred or that the host recovered. Save application data and stop submitting new work first: this command does not drain the fleet or prevent new work during the delay. See Controller shutdown and reboot.

Reuse the same command ID when redelivering a request. Durable deduplication prevents another host request, including after Fleet Manager restarts. An interrupted command is marked failed with an uncertain outcome and is never replayed. Query power status before deciding whether to issue a new command ID.

kerrigan.power.status​

Read-only command with the same required system_id argument and target validation. Returns system_id and the host's persistent power status. A later boot_observed state indicates a new host boot, not proof of graceful shutdown. It remains available while device work is active. Existing command.status can also retrieve the durable command result using the original reboot command ID.

The cloud-control CLI binds these commands to the resolved system ID:

kerrigan-cloud-control-cli --system <system-name> send kerrigan.reboot
kerrigan-cloud-control-cli --system <system-name> send kerrigan.power.status

devices.powerctl​

Controls the physical power supply for selected DevKits using USB relay channels configured by an administrator in Kerrigan. This works even when a DevKit is powered off or unreachable, provided its Kerrigan controller and relay are available.

{
"schema_version": 1,
"command_id": "cmd-power-unique-id",
"correlation_id": "ui-request-unique-id",
"requester_id": "backoffice:operator@example.com",
"operation": "devices.powerctl",
"arguments": {
"target_ids": ["modalix-123", "modalix-456"],
"action": "cycle"
}
}
ArgumentContract
actionRequired: on, off, or cycle. These describe DevKit power, with NC/NO wiring handled locally.
target_idsNonempty array of IDs belonging to the receiving controller. Duplicates are removed.
pool_idAlternative to target_ids; supply exactly one selector.

cycle switches power off for 5 seconds and then on. Devices in a multi-target request are operated sequentially. All targets and relay availability are checked before changing any output. A hardware failure stops the request: preceding results are published in command.progress, the failed target is identified, and remaining targets are not changed. This is not a transactional batch.

Power requests share the local HTTP power manager and lock, including channel assignment and the entire power-cycle interval. No relay channel configuration is accepted over this API. Commands may interrupt active device work; they do not wait for a DevKit to become idle or reboot successfully.

The completed result contains only the requested devices:

{
"devices": [
{"target_id": "modalix-123", "available": true, "powered": true, "error": null},
{"target_id": "modalix-456", "available": true, "powered": true, "error": null}
]
}

powered is relay-output readback, not a voltage measurement or boot health check. It can be null when relay readback is unavailable or a USB error occurs. A failed or interrupted command does not establish device power state. A cycle attempts to restore power on errors, but process termination or lost USB power can prevent that restoration.

Reuse the same command_id for redelivery. Durable deduplication prevents a second physical operation. After a controller restart, unfinished power commands are marked failed with unknown power state; they are never automatically replayed. Read status before issuing a new command ID.

devices.power.status​

Read-only query with target_ids or pool_id and no action. Returns status for only the selected devices without changing relay outputs.

FT245R status queries read the USB pin levels without initializing output mode or writing relay outputs. Readback is available after a Kerrigan restart without first issuing a power operation; a failed read is reported as unknown with an error.

Request example​

{
"schema_version": 1,
"command_id": "cmd-power-status-123",
"correlation_id": "ui-status-123",
"requester_id": "backoffice:operator@example.com",
"operation": "devices.power.status",
"arguments": {
"target_ids": ["modalix-123", "modalix-456"]
}
}

Completed response: known power states​

Responses arrive as MQTT lifecycle events on the event topics described in Topic contract. The final command.completed event carries status under result.devices. For example, one device powered on and one off:

{
"schema_version": 1,
"event_id": "evt-cmd-power-status-123-1789531121248705540",
"event_type": "command.completed",
"command_id": "cmd-power-status-123",
"sequence": 6,
"correlation_id": "ui-status-123",
"requester_id": "backoffice:operator@example.com",
"operation": "devices.power.status",
"occurred_at": "2026-09-16T03:58:41.248713+00:00",
"state": "completed",
"progress_percent": 100,
"result": {
"devices": [
{"target_id": "modalix-123", "available": true, "powered": true, "error": null},
{"target_id": "modalix-456", "available": true, "powered": false, "error": null}
]
}
}

Completed response: unknown or unavailable power state​

The following is an alternative result object for the same selection:

{
"devices": [
{"target_id": "modalix-123", "available": true, "powered": null, "error": null},
{"target_id": "modalix-456", "available": false, "powered": null, "error": null}
]
}

Here, modalix-123 has an available relay assignment but no known output state (for example, when a relay driver cannot provide readback). modalix-456 has no usable relay assignment, such as an unconfigured channel or a disconnected relay. Check its configuration and relay connection in Kerrigan.

Result fieldMeaning
target_idRequested DevKit ID.
availableWhether the device has a usable relay assignment at query time.
poweredtrue for power on, false for power off, or null when unknown. Reflects relay output with NC/NO wiring applied, not measured voltage or boot health.
errorRelay discovery/readback error text, when present; otherwise null. An unavailable assignment does not necessarily produce an error.

A completed status query can contain unavailable devices or per-device errors; completed means the query finished, not that every device is powered or healthy. Kerrigan also emits a command.progress event for each device with its status under detail.devices. Invalid requests and execution failures use the standard failed or rejected event format.

devices.boot​

Requests a reboot for one or more DevKits. Select targets with exactly one of target_ids or pool_id. Kerrigan rejects a request containing both selectors.

{
"arguments": {
"target_ids": ["modalix-123", "modalix-456"]
}
}

or:

{
"arguments": {
"pool_id": "validation-lab"
}
}

The completed event reports how each target was handled:

{
"result": {
"accepted": ["modalix-123"],
"missing": [],
"unavailable": [],
"offline": ["modalix-456"],
"already_rebooting": []
}
}

A completed command means Kerrigan accepted and issued at least one reboot request. Device heartbeat/status should be used to confirm that the device came back online.

devices.ota_update​

Starts the latest applicable firmware update for selected DevKits and reports job progress until all started jobs terminate.

{
"arguments": {
"target_ids": ["modalix-123"],
"release_channel": "ga"
}
}

release_channel is ga by default and may also be pre-release. A successful terminal event contains result.jobs; targets for which no job could be started are listed in result.skipped. If every target is skipped, or a started job does not complete successfully, the command terminates with command.failed.

devices.cleanup​

Resets selected DevKits for safe reuse by deleting workload data while preserving Kerrigan-managed bundles, sima-cli, pyneat, device identity, and runner configuration. Select devices with exactly one of target_ids or pool_id; reason is optional and is limited to 500 characters.

{
"arguments": {
"pool_id": "validation-lab",
"reason": "Prepare for the next user"
}
}

Admission is atomic across the selected devices. Every device must be online, healthy, idle, and free of active jobs and leases before any cleanup starts. An active maintenance window is allowed. Progress events contain a bounded durable-job summary under detail.cleanup; the completed result contains the same summary under result.cleanup. Per-path evidence remains available from the local REST API.

A cleanup failure or controller restart can leave the job in recovery_required. Kerrigan retains the device reservation in that state, so the device is not scheduled for new work. Inspect and retry the durable job through the local REST API before reuse. See Reset DevKits for Reuse for preservation, filesystem, and platform 3.0 behavior.

devices.maintenance​

For local dashboard and REST controls, including appliance and pool scopes, see Maintenance.

Set persistent device maintenance intent using an explicit device list. Cloud UI appliance/pool selections expand to device IDs before submission; this operation changes device scopes, not the persistent appliance/pool maintenance scopes.

{
"schema_version": 1,
"command_id": "maintenance-example-001",
"correlation_id": "maintenance-example-001",
"requester_id": "backoffice:operator",
"operation": "devices.maintenance",
"issued_at": "2026-09-16T12:00:00Z",
"arguments": {
"target_ids": ["devkit-1", "devkit-2"],
"enabled": true,
"reason": "Software update"
}
}

enabled must be a boolean. Select 1–64 devices; reason is optional and at most 500 characters. Set enabled: false to leave device maintenance. The entire selection is validated before changes begin. Inherited appliance/pool maintenance rejects the request; clear the controlling scope in Kerrigan first.

A completed command acknowledges saved intent, not completed runner operations:

{
"intent_saved": true,
"devices": [{
"target_id": "devkit-1",
"maintenance": {
"enabled": true,
"blocked": true,
"phase": "entering",
"scopes": [{"scope": "device", "scope_id": "devkit-1", "reason": "Software update"}],
"services": [],
"error": null
}
}]
}

Follow devkits[].maintenance in cloud state until phase is active (ready for maintenance), or normal after exit. Errors remain blocked and are retried by Kerrigan. New work and SDK pairing remain blocked while runner restoration is pending.

Maintenance window lifecycle​

A maintenance window is an interval explicitly opened and closed by the caller. The API stores persistent intent: it has no start time, duration, or automatic expiry. Intent survives controller restarts until explicitly cleared.

  1. Check a fresh report for kerrigan.maintenance_control.available. This advertises controller support; it does not mean a device is already in maintenance.
  2. Send devices.maintenance with enabled: true and the explicit device IDs. A command.completed event acknowledges saved intent only; the response shown above is its detail object.
  3. Wait for each selected device's maintenance.phase to become active before beginning maintenance. Kerrigan waits for active work to finish and reconciles runner services. Offline devices or busy runners may delay entry.
  4. Perform the maintenance operation. A firmware update finishing does not close the maintenance window automatically.
  5. Send a new command ID with the same device IDs and enabled: false. Wait for phase: "normal" and blocked: false before treating devices as available. Runner restoration can remain pending or fail after exit intent is saved.

If reconciliation reports error, inspect the reported reason; Kerrigan keeps admission blocked and retries. Do not interpret command completion, heartbeat arrival, or enabled: false alone as readiness for new work.

Only target_ids, enabled, and optional reason are accepted as arguments. pool_id, appliance scope, and scheduling fields are not supported by this operation. A cloud caller may expand a local pool selection to explicit device IDs, but that captures the current selection: future pool members do not inherit this command's device-scoped intent. Pool membership is described separately.

CLI reference:

kerrigan-cloud-control-cli --system ll2 maintenance on --target devkit-1 --target devkit-2 --reason "Software update"
kerrigan-cloud-control-cli --system ll2 maintenance off --target devkit-1 --target devkit-2

Maintenance admission​

devices.sdk.connect is rejected with error.code: "DeviceInMaintenance" when any global, pool, or device maintenance scope applies, or runner restoration is pending/failed. The message identifies the device and reason. Kerrigan checks at submission and again before pairing side effects. Authorized cloud terminal reservations remain available for online, idle devices with no active execution lease; closing a terminal preserves the maintenance scheduling block. Status, power, device reboot, and cleanup operations remain available to authorized callers. OTA updates, device bundle rollouts, and Developer Portal credential projection require the device to reach active; they remain blocked during entering, exiting, and error. See the complete maintenance admission matrix.

ICE Peer Pairing commands​

devices.p2p.reserve and devices.p2p.release are allocator-only commands. A developer must never receive MQTT credentials that can publish them. The allocator decides whether a developer is authorized and selects a DevKit; Kerrigan atomically admits or releases the local device lease. This division keeps maintenance, scheduler ownership, device health, and cleanup checks at the appliance that controls the DevKit.

Both commands require exactly one target_ids entry and a 32-character, lowercase hexadecimal allocation session_id. They use the standard command envelope, command-specific event topics, and durable command_id idempotency.

devices.p2p.reserve​

Reserves one eligible DevKit for a developer allocation. The allocator chooses the allocation identity and deadline; Kerrigan rejects the request when the device is not authorized by its profile, is stale, unhealthy, busy, in maintenance, undergoing firmware work, or already held by another lease.

{
"arguments": {
"session_id": "0123456789abcdef0123456789abcdef",
"target_ids": ["modalix-123"],
"expires_at": 1760000000,
"connect_by": 1759997000
}
}
ArgumentContract
session_idRequired allocator-generated allocation ID. Reuse it only when redelivering the same allocation request.
target_idsRequired array containing exactly one DevKit ID.
expires_atRequired Unix timestamp for the allocation end. Kerrigan accepts a future deadline of at most seven days.
connect_byRequired Unix timestamp for the first successful connection. Kerrigan accepts a future deadline of at most ten minutes. The allocator should choose a deadline within the allocation period.

A completed result contains the persisted deadlines:

{
"result": {
"session_id": "0123456789abcdef0123456789abcdef",
"status": "reserved",
"expires_at": 1760000000,
"connect_by": 1759997000
}
}

reserved means Kerrigan drained the DevKit from normal scheduling and wrote its local allocation and P2P lease. It does not mean an ICE or WireGuard peer has connected. The allocator must not offer the device to another developer until a completed release result is observed.

devices.p2p.release​

Releases a previously reserved DevKit. For an ICE allocation, publish devices.p2p.ice.stop first and wait for its closed result; this proves the device forwarder and its runtime session material were removed before the allocation is released.

{
"arguments": {
"session_id": "0123456789abcdef0123456789abcdef",
"target_ids": ["modalix-123"]
}
}

The completed result is {"session_id": "…", "status": "closed"}. It means Kerrigan removed the allocation and local P2P lease, then restored the DevKit to normal scheduling unless another local safety condition still blocks it. A stale release for an absent or newer lease also returns closed, making allocator retry safe.

ICE session lifecycle​

devices.p2p.ice.start and devices.p2p.ice.stop are allocator-only control-plane commands used by an allocation and signaling service. They are not a user-facing API: callers must authorize an allocation before publishing either command. See ICE Peer Pairing for the architecture, required infrastructure, workflow, and recovery rules.

Both commands require exactly one target_ids entry and a 32-character, lowercase hexadecimal session_id. They use the normal command envelope and the same command-specific event topics and idempotency semantics as every other control operation.

devices.p2p.ice.start​

Starts the device-role native ICE forwarder for an existing, eligible DevKit allocation. Kerrigan rejects the command unless all of the following are true:

  • the paired appliance profile explicitly authorizes the target and declares the ICE capability;
  • the target is healthy, not in maintenance, and is idle or already owned by this session;
  • the device's allocation and P2P lease exist, are still valid, and are not owned by another peer session; and
  • the signaling URL, STUN server, and TURN server exactly match the appliance's paired profile.
{
"arguments": {
"session_id": "0123456789abcdef0123456789abcdef",
"target_ids": ["modalix-123"],
"expires_at": 1760000000,
"signaling_url": "wss://signaling.example.invalid",
"signaling_token": "<device-role-token>",
"stun_server": "<configured-stun-server>",
"turn": {
"server": "<configured-turn-server>",
"username": "<ephemeral-username>",
"password": "<ephemeral-password>",
"expires_at": 1760000000
}
}
}
ArgumentContract
session_idRequired 32-character session identity. It must identify the active allocation-bound ICE session.
target_idsRequired array containing exactly the allocated DevKit ID.
expires_atRequired Unix timestamp, later than the current appliance time and no more than 30 minutes away. It cannot outlive the appliance allocation lease.
signaling_urlRequired TLS WebSocket URL. It must be an exact profile match and cannot contain embedded credentials, a query, or a fragment.
signaling_tokenRequired device-role, session-scoped signaling credential. Treat it as a secret. Kerrigan passes it directly to the device agent but excludes it from durable command history and command results.
stun_serverRequired configured STUN endpoint, exact profile match.
turnRequired ephemeral TURN configuration. Its server must be an exact profile match. Treat credentials as secrets; Kerrigan does not retain them.

The completed result is intentionally small:

{
"result": {
"session_id": "0123456789abcdef0123456789abcdef",
"status": "started"
}
}

started means the device agent accepted the session and launched its native forwarder. It does not mean that ICE connected, that a direct path was selected, or that relayed data traffic has been verified.

devices.p2p.ice.stop​

Stops the device-role forwarder for a session. Publish it after user disconnect, allocation release, expiry, or a failed negotiation. The command is safe to retry when delivery or event observation is uncertain.

{
"arguments": {
"session_id": "0123456789abcdef0123456789abcdef",
"target_ids": ["modalix-123"],
"reason": "allocation_released"
}
}
ArgumentContract
session_idRequired active ICE session identity. A delayed stop cannot remove a newer session.
target_idsRequired array containing exactly one DevKit ID.
reasonOptional bounded caller reason used for lifecycle diagnostics. It does not change authorization or cleanup semantics.

The successful result is {"session_id": "…", "status": "closed"}. It means the device agent removed its session runtime state and Kerrigan cleared the target's ICE pairing state. A missing or already-stopped session also returns closed, making cleanup retry-safe. Kerrigan retains the underlying allocation until its normal release flow finishes.

SDK connection commands​

devices.sdk.connect​

Pairs one DevKit with an SDK endpoint using WireGuard and an NFSv4 workspace. The DevKit generates its WireGuard key pair locally. The private key is written directly to a mode-0600 file and is never returned through SSH, MQTT, command storage, or command logs.

{
"arguments": {
"target_ids": ["bootstrap-68e154065848"],
"connection_id": "sdk-4d77197cb5ed4f98a1c6a97191dcc2f1",
"wireguard": {
"server_public_key": "base64-encoded-32-byte-key",
"endpoint": "203.0.113.10:51820",
"client_address": "10.77.0.42/32",
"server_address": "10.77.0.1/32"
},
"nfs": {
"server_address": "10.77.0.1",
"export": "/srv/kerrigan/sdk",
"mount_point": "/workspace"
},
"install_tools": false
}
}

connection_id identifies one pairing attempt and makes DevKit key generation idempotent. Repeating preparation with the same ID reuses the existing local key. A different ID replaces the previous local connection.

See SDK Connection for the sequence diagram, endpoint setup responsibilities, key ownership, retries, and cleanup.

Your integration must authorize the DevKit public key on the SDK endpoint when it receives awaiting_endpoint_peer. Kerrigan continues DevKit setup without waiting for a separate MQTT acknowledgement. Completion requires a successful WireGuard handshake and NFS mount.

The public-key progress event has this relevant shape:

{
"event_type": "command.progress",
"progress_percent": 25,
"detail": {
"sdk_connection": {
"phase": "awaiting_endpoint_peer",
"connection_id": "sdk-4d77197cb5ed4f98a1c6a97191dcc2f1",
"target_id": "bootstrap-68e154065848",
"devkit_public_key": "base64-encoded-32-byte-key"
}
}
}

Subscribe before publishing and make endpoint peer authorization safe to repeat. command_id deduplicates the MQTT command; connection_id identifies the pairing. A completed result contains connection_id, target_id, wireguard (including connected and latest_handshake_epoch), and nfs (including mounted, source, and mount_point). Private keys never appear in events or results.

devices.sdk.disconnect​

Unmounts NFS, removes its managed /etc/fstab entry, disables WireGuard, and deletes the DevKit key material:

{
"arguments": {
"target_ids": ["bootstrap-68e154065848"],
"connection_id": "sdk-4d77197cb5ed4f98a1c6a97191dcc2f1"
}
}

The connection ID must match the active local connection, preventing a stale disconnect from removing a newer pairing. On success, the DevKit has:

  1. Unmounted the NFS workspace.
  2. Removed the managed NFS entry from /etc/fstab.
  3. Stopped and disabled wg-quick@wg-kerrigan.
  4. Deleted its WireGuard configuration, private key, public key, and SDK state.

The cloud controller remains responsible for removing the endpoint peer, and must apply the same connection-ID check there. The cloud control CLI runs the DevKit cleanup first and removes the matching endpoint peer through AWS Systems Manager only after Kerrigan reports successful cleanup. If the NFS workspace is busy, disconnect kills processes using that filesystem, retries the unmount, and verifies that the mount is gone before stopping WireGuard or deleting state. If forced unmount still fails, both peers and local state are preserved for a safe retry. A stale endpoint disconnect is refused rather than removing a newer connection.

Browser terminal commands​

devices.ssh.open​

Starts an on-demand terminal through an explicitly configured WebSocket relay operated by the integrating cloud service. The controller owns SSH/PTY and validates the local target, login and relay URL against its paired cloud profile. SSH host keys are accepted without a managed known-hosts file. See Browser Terminal Access for paired-cloud defaults, the command contract, and session limits.

The one-use controller_ticket argument must never be logged or persisted. Kerrigan removes it from durable command history. The integrating service is responsible for authorizing operators, issuing separate client and controller grants, and publishing this command through the authenticated control channel. The operation does not prescribe a cloud provider, session-management REST API, or operator identity system. The relay must implement the protocol documented in Browser Terminal Access.

Request example (publish to the controller's command topic):

{
"schema_version": 1,
"command_id": "cmd-ssh-open-0123456789abcdef",
"correlation_id": "request-support-123",
"requester_id": "cloud:operator@example.com",
"operation": "devices.ssh.open",
"issued_at": "2026-09-07T12:00:00Z",
"arguments": {
"target_ids": [
"modalix-123"
],
"session_id": "0123456789abcdef0123456789abcdef",
"websocket_url": "wss://terminal.example.com/sessions",
"controller_ticket": "0123456789abcdef0123456789abcdef.controller.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"login": "sima",
"protocol_version": 1,
"grant_deadline": 1788782460,
"deadline": 1788784200,
"idle_seconds": 600
}
}

The ticket above is a format-only placeholder, not a usable credential. Your session service must mint a fresh, one-use grant and replace issued_at and both deadlines when submitting the command. The example allows 60 seconds to attach, 30 minutes of session lifetime, and 10 minutes without input. The target must be online and idle; the URL and login must match the paired profile (URL) and the default sima/user login list.

Opening the PTY does not complete this long-running MQTT command. Readiness is sent as a ready frame over WSS. After the shell exits normally, a completed event contains:

{
"result": {
"session_id": "0123456789abcdef0123456789abcdef",
"state": "exited"
}
}

A timeout or stopped session returns state: "closed". Relay disconnects also return reason: "relay_disconnected". Validation and connection failures produce command.failed; use the normal command event stream to inspect the outcome.

Closing a terminal session​

Use devices.ssh.close to cancel a specific active session over the authenticated MQTT control channel. Alternatively, your session service can revoke its grants and close both paired WSS connections. The hard and idle deadlines also terminate the session locally.

Each accepted session opens its own controller WSS connection and SSH PTY. Closing a session for one DevKit does not close another DevKit's session on the same Kerrigan. A device can have only one managed terminal session at a time; Kerrigan currently permits up to two terminal sessions across different devices.

devices.ssh.close​

Closes one active terminal by its exact session_id, independently of the relay's session-management API. It does not accept a DevKit selector or close other sessions. The command remains available when the paired cloud profile has been removed, provided the command still reaches the authenticated dispatcher.

{
"schema_version": 1,
"command_id": "cmd-ssh-close-0123456789abcdef",
"correlation_id": "request-support-close-123",
"requester_id": "cloud:operator@example.com",
"operation": "devices.ssh.close",
"issued_at": "2026-09-07T12:05:00Z",
"arguments": {
"session_id": "0123456789abcdef0123456789abcdef"
}
}

session_id must be 32 lowercase hexadecimal characters. Your service must retain the ID from devices.ssh.open, never reuse it for another session, and authorize the caller to terminate that session before publishing the command. The MQTT transport's command-publishing permissions are Kerrigan's authorization boundary; requester_id is attribution, not a separate permission check.

A completed close waits for the active session worker to exit and release its device reservation. The open command also finishes on its own event stream.

{
"result": {
"session_id": "0123456789abcdef0123456789abcdef",
"state": "closed",
"already_closed": false
}
}

If no session with that ID is active, the command succeeds with already_closed: true. Repeated closes are safe; a delayed close for an old ID cannot close a newer session on the same DevKit. Closing one session does not affect another DevKit's WSS connection or PTY.

Cancellation during connection setup waits for the current bounded network operation to return. The close handler waits up to 45 seconds for completion. If cleanup remains pending or reservation cleanup fails, it reports command.failed rather than claiming the session is closed. Cancellation remains requested. To check again after a failed close, submit a new command ID with the same session ID; retrying the original command ID returns its recorded outcome.

This operation acts on sessions already registered in the running controller. An unknown-session close does not create a cancellation record for a future open, and it does not revoke cloud grants. Your service should revoke the session's grants as well, so a queued open cannot connect after an earlier close. Normal relay disconnect cleanup should release any cloud-side reservations. Closing a shell does not guarantee termination of processes intentionally detached from it.

Command history​

command.status​

Returns the persisted event history for an earlier command.

{
"arguments": {
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975"
}
}

The status request itself must have a new command ID. Its completed event contains the requested command ID, a byte-bounded ordered tail of the newest events, and a compact last_event summary. event_count, events_returned, and history_truncated indicate whether older events were omitted to keep the MQTT response below the control-event payload limit:

{
"result": {
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"events": [
{
"event_type": "command.progress",
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"sequence": 22,
"state": "running"
},
{
"event_type": "command.progress",
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"sequence": 23,
"state": "running"
},
{
"event_type": "command.completed",
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"sequence": 24,
"state": "completed"
}
],
"event_count": 24,
"events_returned": 3,
"history_truncated": true,
"last_event": {
"event_type": "command.completed",
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"sequence": 24,
"state": "completed"
}
}
}

Event messages​

Kerrigan publishes every lifecycle transition as a separate JSON object to both the aggregate event topic and the command-specific event topic.

{
"schema_version": 1,
"event_id": "evt-cmd-9f884cc778dc4e87884ab0c5a82ff975-...",
"event_type": "command.progress",
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"sequence": 3,
"correlation_id": "request-18a75fe1",
"requester_id": "cloud:operator@example.com",
"operation": "devices.boot",
"occurred_at": "2026-09-02T22:40:00.123456+00:00",
"state": "running",
"progress_percent": 0,
"message": "Boot requests issued",
"detail": {}
}

Event fields​

FieldTypePresentDescription
schema_versionintegeralwaysEvent schema version; currently 1.
event_idstringalwaysUnique event ID.
event_typestringalwaysLifecycle event type.
command_idstringalways for a valid commandID from the command envelope.
sequenceintegeralwaysPer-command ordering number beginning at 1.
correlation_idstringalways for a valid commandCaller correlation ID returned unchanged.
requester_idstringalways for a valid commandRequester from the command.
operationstringalways for a valid commandRequested operation.
occurred_atRFC 3339 stringalwaysKerrigan event time in UTC.
statestringnormallyreceived, accepted, running, completed, failed, or rejected.
progress_percentnumber or nulloptionalCoarse command progress when available. Completed events contain 100; failed and rejected events can omit it.
messagestringoptionalHuman-readable progress or rejection explanation.
detailobjectoptionalOperation-specific progress details.
resultobjectcompletedOperation-specific result.
errorobjectfailed/rejectedError code and human-readable message.

Normal lifecycle:

command.received → command.accepted → command.progress (one or more)
→ command.completed | command.failed

The terminal event types are:

Event typeMeaning
command.completedOperation finished successfully.
command.failedAn accepted operation failed during execution. Inspect error.
command.rejectedEnvelope, operation, or target validation failed before acceptance. Inspect message or error.message.
command.duplicateThis command ID was already claimed. The event has a new event_id, retains the last persisted event's sequence and state, and does not run the operation again. It is a retry response rather than a new persisted lifecycle transition.

Delivery, ordering, and concurrency​

AWS IoT MQTT QoS 1 is an at-least-once transport. A caller must:

  1. Generate a globally unique command ID and subscribe to its event topic.
  2. Publish the command with QoS 1 and without the retain flag.
  3. Ignore messages whose command_id does not match the submitted command.
  4. Deduplicate events by event_id and order them by integer sequence.
  5. Continue until a terminal event arrives; do not assume MQTT arrival order is lifecycle order.
  6. On an uncertain publish outcome, retry the same command with the same command_id. Kerrigan persists the claim and will not execute it twice.
  7. Use a new MQTT client ID for each concurrent caller connection.

Kerrigan tracks concurrent commands independently by command ID and executes them in separate workers. This does not serialize conflicting operations on the same DevKit. Callers should avoid sending overlapping boot and OTA operations to the same target. The supplied staging WireGuard endpoint is intentionally single-peer; a new CLI connection replaces its existing endpoint peer.

Rejection example​

{
"schema_version": 1,
"event_id": "evt-rejected-...",
"event_type": "command.rejected",
"sequence": 1,
"command_id": "cmd-9f884cc778dc4e87884ab0c5a82ff975",
"correlation_id": "request-18a75fe1",
"requester_id": "cloud:operator@example.com",
"operation": "devices.boot",
"state": "rejected",
"message": "DevKit target not found in this Kerrigan system: missing-kit",
"occurred_at": "2026-09-02T22:40:00.123456+00:00",
"error": {
"code": "ControlCommandError",
"message": "DevKit target not found in this Kerrigan system: missing-kit"
}
}

Authentication and authorization​

Cloud callers normally connect to AWS IoT Core over MQTT WebSockets using AWS IAM credentials. The IAM role needs permission to connect, publish to the selected cluster's command topic, and subscribe and receive on that cluster's command-event topics. Explicit mTLS clients must use their own certificate and MQTT client ID; reusing Kerrigan's server identity can disconnect the server.

Kerrigan records requester_id for attribution, but the trusted cloud caller is responsible for authenticating and authorizing the requester before publishing.