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.
| Direction | Topic | Purpose |
|---|---|---|
| Caller → Kerrigan | vulcan/{environment}/kerrigan/clusters/{cluster_id}/commands | Submit commands. |
| Kerrigan → callers | kerrigan/{environment}/clusters/{cluster_id}/events | Aggregate event stream for the system. |
| Kerrigan → caller | kerrigan/{environment}/clusters/{cluster_id}/commands/{command_id}/events | Events 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
| Field | Type | Required | Description |
|---|---|---|---|
schema_version | integer | yes | Must be 1. |
command_id | string | yes | Globally unique, stable ID assigned by the caller. Use the same value when retrying the same logical command. |
correlation_id | string | yes | Caller-owned request, trace, or workflow ID returned unchanged in every event. |
requester_id | string | yes | Identity of the user or service requesting the operation. |
operation | string | yes | One of the supported operation names below. |
issued_at | RFC 3339 string | recommended | Time at which the caller created the command. |
arguments | object | no | Operation 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"
}
}
| Argument | Contract |
|---|---|
action | Required: on, off, or cycle. These describe DevKit power, with NC/NO wiring handled locally. |
target_ids | Nonempty array of IDs belonging to the receiving controller. Duplicates are removed. |
pool_id | Alternative 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 field | Meaning |
|---|---|
target_id | Requested DevKit ID. |
available | Whether the device has a usable relay assignment at query time. |
powered | true 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. |
error | Relay 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.
- Check a fresh report for
kerrigan.maintenance_control.available. This advertises controller support; it does not mean a device is already in maintenance. - Send
devices.maintenancewithenabled: trueand the explicit device IDs. Acommand.completedevent acknowledges saved intent only; the response shown above is itsdetailobject. - Wait for each selected device's
maintenance.phaseto becomeactivebefore beginning maintenance. Kerrigan waits for active work to finish and reconciles runner services. Offline devices or busy runners may delay entry. - Perform the maintenance operation. A firmware update finishing does not close the maintenance window automatically.
- Send a new command ID with the same device IDs and
enabled: false. Wait forphase: "normal"andblocked: falsebefore 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
}
}
| Argument | Contract |
|---|---|
session_id | Required allocator-generated allocation ID. Reuse it only when redelivering the same allocation request. |
target_ids | Required array containing exactly one DevKit ID. |
expires_at | Required Unix timestamp for the allocation end. Kerrigan accepts a future deadline of at most seven days. |
connect_by | Required 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
}
}
}
| Argument | Contract |
|---|---|
session_id | Required 32-character session identity. It must identify the active allocation-bound ICE session. |
target_ids | Required array containing exactly the allocated DevKit ID. |
expires_at | Required Unix timestamp, later than the current appliance time and no more than 30 minutes away. It cannot outlive the appliance allocation lease. |
signaling_url | Required TLS WebSocket URL. It must be an exact profile match and cannot contain embedded credentials, a query, or a fragment. |
signaling_token | Required 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_server | Required configured STUN endpoint, exact profile match. |
turn | Required 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"
}
}
| Argument | Contract |
|---|---|
session_id | Required active ICE session identity. A delayed stop cannot remove a newer session. |
target_ids | Required array containing exactly one DevKit ID. |
reason | Optional 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:
- Unmounted the NFS workspace.
- Removed the managed NFS entry from
/etc/fstab. - Stopped and disabled
wg-quick@wg-kerrigan. - 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
| Field | Type | Present | Description |
|---|---|---|---|
schema_version | integer | always | Event schema version; currently 1. |
event_id | string | always | Unique event ID. |
event_type | string | always | Lifecycle event type. |
command_id | string | always for a valid command | ID from the command envelope. |
sequence | integer | always | Per-command ordering number beginning at 1. |
correlation_id | string | always for a valid command | Caller correlation ID returned unchanged. |
requester_id | string | always for a valid command | Requester from the command. |
operation | string | always for a valid command | Requested operation. |
occurred_at | RFC 3339 string | always | Kerrigan event time in UTC. |
state | string | normally | received, accepted, running, completed, failed, or rejected. |
progress_percent | number or null | optional | Coarse command progress when available. Completed events contain 100; failed and rejected events can omit it. |
message | string | optional | Human-readable progress or rejection explanation. |
detail | object | optional | Operation-specific progress details. |
result | object | completed | Operation-specific result. |
error | object | failed/rejected | Error 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 type | Meaning |
|---|---|
command.completed | Operation finished successfully. |
command.failed | An accepted operation failed during execution. Inspect error. |
command.rejected | Envelope, operation, or target validation failed before acceptance. Inspect message or error.message. |
command.duplicate | This 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:
- Generate a globally unique command ID and subscribe to its event topic.
- Publish the command with QoS 1 and without the retain flag.
- Ignore messages whose
command_iddoes not match the submitted command. - Deduplicate events by
event_idand order them by integersequence. - Continue until a terminal event arrives; do not assume MQTT arrival order is lifecycle order.
- On an uncertain publish outcome, retry the same command with the same
command_id. Kerrigan persists the claim and will not execute it twice. - 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.