32 KiB
BOX-API-DESIGN-TIMERS.md: Timer & Job Endpoint Design
Status: Design only. No implementation.
Owner: operator-main. Sanctioned direction from human 2026-10-04.
Supersedes: the endpoint sketches in BOX-API-SPEC.md §§ "Endpoints"
(which are kept as background; this doc is the buildable contract).
1. Scope
Design the request/response schemas and security model for the
agentic timer/job management plane on box.muse-dev.online:
GET/POST/DELETE /api/box/timers— systemd timer lifecycleGET/POST/PUT/DELETE /api/box/jobs— job definition lifecyclePOST /api/box/jobs/{name}/trigger— manual job dispatchGET/POST /api/box/requests— the[REQ]/[CONFIRM]flow
Out of scope: /api/box/dms, /api/box/board/*, /api/box/chat/*,
/api/box/message (covered by BOX-API-SPEC.md appendices B/C; they
reuse the auth and audit patterns defined here).
2. Ground truths (read before implementing)
Reality on 2026-10-04, verified live. The design conforms to these; where BOX-API-SPEC.md v0.1 disagrees, this doc wins.
- Job definitions are JSON, not YAML. bl has Python 3.14.5 and no
PyYAML. Canonical path:
/home/super/Projects/NetVM/jobs/<name>.json. The API accepts and returnsapplication/jsononly. (A YAML frontend may be added later with a vendored parser; it is not part of v1.) - The dispatcher is
bin/job-dispatch.py <job_name> [--dry-run]. It renders the prompt template, handles sidechat create/reuse (viajob-sidechats.json:reuse_key → thread UUID), sends viadm.py/ direct sidechat send, and logs tojob-log.jsonl. - Timers are systemd user units at
~/.config/systemd/user/job-<name>.serviceand~/.config/systemd/user/job-<name>.timer, owned bysuperon bl. The serviceExecStartcallsjob-dispatch.py <name>— nothing else. - The API server is
/srv/board/server.pyon the VM (port 8090, behind Caddy;box.muse-dev.onlineroutes to its box host branch). New endpoints plug into the existing_box_apidispatch, next to/api/box/nodes,/api/box/chats, etc. - Auth already exists:
_box_tier()returns("operator", actor)for PIN-session or operator bearer token, or("agent", ident)for a valid box-signature (identity+ts+sigquery params, skew-checked). Anything else gets 403. Audit helper_box_audit(event, actor, tier, detail)appends to/srv/box/audit.jsonl({ts, event, actor, tier, detail}). - VM → bl path exists:
super@100.123.153.75over tailnet, passwordless SSH from the VM. This is the only channel the API server uses to touch bl.
3. Authorization model
Timer management is privileged: creating a timer schedules future code execution on shared infrastructure. The model follows the fleet trust tiers (operator ≥ agent-identity > dev > anonymous).
3.1 Matrix
| Endpoint | super (PIN) | operator (bearer) | agent (box-sig, allowlisted identity) | dev tier | anonymous |
|---|---|---|---|---|---|
GET /timers, GET /timers/{name} |
✅ | ✅ | ✅ | ❌ | 403 |
POST /timers (create) |
✅ | ✅ | ✅ * | ❌ | 403 |
DELETE /timers/{name} |
✅ | ✅ | ✅ * | ❌ | 403 |
POST /timers/{name}/{start,stop,enable,disable} |
✅ | ✅ | ✅ * | ❌ | 403 |
GET /jobs, GET /jobs/{name} |
✅ | ✅ | ✅ | ❌ | 403 |
POST /jobs, PUT /jobs/{name} |
✅ | ✅ | ✅ * | ❌ | 403 |
DELETE /jobs/{name} |
✅ | ✅ | ✅ * | ❌ | 403 |
POST /jobs/{name}/trigger |
✅ | ✅ | ✅ * | ❌ | 403 |
GET /requests, GET /requests/{id} |
✅ | ✅ | ✅ (own only) | ❌ | 403 |
POST /requests |
✅ | ✅ | ✅ * | ❌ | 403 |
* Agent box-signatures are accepted only if the identity is in the
operator allowlist (/srv/board/allowed_signers entries flagged
box_mutate). A box-signature from a non-operator agent (e.g. a dev
agent) is treated as read-capable only and gets 403 on mutations.
Rationale: box-signatures prove which agent, not what tier — the
allowlist binds identity to privilege.
3.2 Why devs get nothing here
Devs execute jobs; they do not schedule them (JOB-SPEC.md § Security: "Agents cannot create jobs"). A dev that could create timers could schedule DM spam or exfiltrate via prompt templates. Read access is also withheld: timer definitions reveal fleet operational tempo. The human may grant exceptions via the duties system; the API checks the grant at request time, it is not hardcoded.
3.3 What "operator" means for agents
operator-main, operator-646, and the node agents (muse, pip,
646, opm) authenticate with box-signatures. Their tier for box
mutations comes from the allowlist, not the signature itself. The
human (super) uses the PIN session. There is no separate "admin"
tier — super is the trust root and can already do everything via
other channels.
4. The constrained bl helper: bin/box-ctl.py
The board server never runs raw systemctl or shell over SSH.
All bl mutations go through one allowlisted helper:
/home/super/Projects/NetVM/bin/box-ctl.py <action> [args...]
Invoked from the VM as:
ssh -o BatchMode=yes super@100.123.153.75 \
/home/super/Projects/NetVM/bin/box-ctl.py timer-status heartbeat
4.1 Why a helper instead of systemctl over SSH
- BOX-API-SPEC.md proposed SSH with a restricted key. A restricted
key still permits
systemctl --user <anything>— includingsystemctl --user edit,link, orset-propertyon attacker-named units. An allowlist inside the helper is a smaller, auditable surface: the helper accepts a fixed verb set and validates every argument before acting. - The helper also owns the safe unit-file generation (template-only, no string interpolation of user content into unit files) and the cron→OnCalendar conversion, keeping that logic next to the dispatcher it serves.
- Defense in depth: the API server validates first (fast 400s), the helper validates again (it is the trust boundary; the API server is reachable from the internet via Caddy).
4.2 Allowlisted actions
| Action | Args | Effect |
|---|---|---|
timer-list |
— | JSON list of job-*.timer units + state |
timer-status |
<name> |
JSON: active/enabled/last/next/result |
timer-create |
<name> |
Read jobs/<name>.json, write .service+.timer from template, daemon-reload, enable --now |
timer-delete |
<name> [--keep-job] |
stop, disable, remove unit files, daemon-reload |
timer-start / timer-stop / timer-enable / timer-disable |
<name> |
the obvious systemctl op on job-<name>.timer |
job-list |
— | JSON list of jobs/*.json (parsed summaries) |
job-get |
<name> |
print the job JSON |
job-put |
<name> |
read job JSON from stdin, schema-validate, write file, git add+git commit |
job-delete |
<name> |
git rm the job JSON + commit |
job-trigger |
<name> |
run job-dispatch.py <name> (foreground; caller decides sync/async) |
The helper prints JSON to stdout ({"ok": true, ...} or
{"ok": false, "error": "...", "code": "..."}) and exits nonzero on
failure. It never prints secrets. It never invokes a shell.
4.3 Validation inside the helper (non-bypassable)
<name>must match^[a-z0-9-]{1,64}$. Anything else → error, no action. (This kills path traversal: no/, no...)- Unit files are generated from a fixed template; the only
interpolated value is
<name>(already regex-validated). TheExecStartis always the absolute path tojob-dispatch.pyplus the name. User-controlled strings (prompt templates, schedules) never appear in unit files. job-putvalidates the full job schema (§6) before writing, including:agent∈ known nodes,scheduleparses as cron and converts to a validOnCalendar(checked withsystemd-analyze calendar --iterations=2),prompt_templatelength ≤ 4000 chars,timeout60–3600,on_failure∈{retry, alert, ignore}.timer-createrefuses if the job JSON is missing or invalid, and refuses if ajob-<name>.timeralready exists (use update flow).- Every action appends to the bl-side log
(
/home/super/Projects/NetVM/box-ctl.jsonl):{ts, action, name, caller}—calleris passed by the API server asBOX_CALLERenv (the authenticated actor).
4.4 SSH hardening (recommended, not load-bearing)
- The VM→bl SSH uses
BatchMode=yes, no agent forwarding, no X11. - Optional hardening: a dedicated
authorized_keysentry on bl withcommand="/home/super/Projects/NetVM/bin/box-ctl.py"for abox-apiSSH key held on the VM, so even a compromised board server process can only invoke the helper. The helper's own validation remains the real boundary.
5. Endpoint designs
Base: https://box.muse-dev.online/api/box.
Auth: §3. All responses are JSON. Mutations are audit-logged
(_box_audit("box.timer.create", actor, tier, {...}) etc.).
Conventions used below:
{name}is the job name (heartbeat), not the unit name. The API mapsname→job-<name>.timerinternally. Clients never construct unit names.dry_run=truequery param: validate everything, report the planned actions, change nothing. Returns200with"dry_run": trueand aplanarray.Idempotency-Keyheader on POST: the server stores{key → response}for 24h in/srv/box/idempotency.jsonl; replays return the original response with200(not 201).- Timestamps are UTC ISO-8601 (
2026-10-04T03:40:00Z).
5.1 Timers
GET /api/box/timers
List all job timers on bl.
Response 200:
{
"timers": [
{
"name": "heartbeat",
"unit": "job-heartbeat.timer",
"active": true,
"enabled": true,
"last_run": "2026-10-04T03:40:00Z",
"next_run": "2026-10-04T03:45:00Z",
"last_result": "success",
"job_name": "heartbeat",
"schedule": "*/5 * * * *"
}
]
}
last_result:success|failed|unknown, derived from the most recentjob_sent/job_result/job_timeoutentries for the job injob-log.jsonl.- Timers with no corresponding job JSON are listed with
"orphan": true(unit exists, definition missing) — never silently dropped.
Errors: 502 (BL_UNREACHABLE) if bl SSH fails.
GET /api/box/timers/{name}
Full status for one timer, including the parsed job definition.
Response 200:
{
"name": "heartbeat",
"unit": "job-heartbeat.timer",
"active": true,
"enabled": true,
"last_run": "2026-10-04T03:40:00Z",
"next_run": "2026-10-04T03:45:00Z",
"last_result": "success",
"oncalendar": "*:0/5",
"job_definition": {
"name": "heartbeat",
"agent": "opm",
"schedule": "*/5 * * * *",
"timeout": 300
}
}
Errors: 404 (NOT_FOUND) — no such timer (or no such job).
POST /api/box/timers
Create a job and its timer atomically. Body is the job definition JSON (§6). This is the primary creation path; it keeps "job exists but timer missing" states unrepresentable.
Request:
POST /api/box/timers
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"name": "board-watch",
"description": "Check board for new posts every 5 minutes",
"schedule": "*/5 * * * *",
"agent": "muse",
"prompt_template": "Check the board for posts since {last_run}. ...",
"timeout": 300,
"on_failure": "alert",
"chain_next": null,
"sidechat": { "create": true, "name_template": "job-{job_name}-{date}", "reuse_key": "board-watch-muse" }
}
Backend sequence (each step validated before the next):
- API server validates JSON schema + authz (§3).
400on failure. box-ctl.py job-put <name>(stdin = body) → writes + validates on bl, git-commits. Failure →400/502, nothing else happens.box-ctl.py timer-create <name>→ writes units from template,daemon-reload,enable --now. Failure → job JSON is kept (it is valid and committed) and the response is502with"timer_created": falseso the client can retry creation without resubmitting the job.- Audit log entry. Optional
[REQ]confirmation DM (§8).
Response 201:
{
"name": "board-watch",
"unit": "job-board-watch.timer",
"created": true,
"timer_created": true,
"active": true,
"next_run": "2026-10-04T04:00:00Z",
"request_id": "a1b2c3d4"
}
Errors:
400(INVALID_JOB) — schema validation failed;detailnames the offending field and reason.409(ALREADY_EXISTS) — job name or timer already exists. The client shouldPUT /jobs/{name}orDELETEfirst.502(BL_UNREACHABLE,TIMER_CREATE_FAILED).
DELETE /api/box/timers/{name}
Stop, disable, and remove the timer. The job definition is kept by default (timers are runtime state; definitions are config).
Query params: keep_job=true|false (default true).
Backend: box-ctl.py timer-delete <name> [--keep-job].
Response 200:
{
"name": "heartbeat",
"deleted": true,
"job_kept": true
}
Errors: 404 (NOT_FOUND), 502.
POST /api/box/timers/{name}/start (also /stop, /enable, /disable)
Control a timer. One action per endpoint; the action is in the path, not the body, so there is nothing to inject.
Response 200:
{
"name": "heartbeat",
"action": "stop",
"success": true,
"active": false,
"enabled": true
}
Errors: 404, 502. POST .../start on an already-active
timer is 200 with "success": true (idempotent).
5.2 Jobs
Jobs are definitions; timers are runtime. These endpoints manage definitions without touching timers (use §5.1 to create both together).
GET /api/box/jobs
Response 200:
{
"jobs": [
{
"name": "heartbeat",
"description": "Heartbeat job - verifies DM system ...",
"schedule": "*/5 * * * *",
"agent": "opm",
"has_timer": true,
"timer_active": true,
"timeout": 300
}
]
}
GET /api/box/jobs/{name}
Response 200: the full job JSON ({"name": ..., "definition": {...}} — or the definition object directly; v1 uses
{"name": "<name>", "definition": {<full job json>}}).
Errors: 404 (NOT_FOUND).
POST /api/box/jobs
Create (or replace, with ?replace=true) a job definition without
creating a timer. For definitions that will be wired to a timer
later, or timerless jobs triggered manually / by chains.
Same body schema as POST /timers (§6). Backend: box-ctl.py job-put.
Response 201: {"name": "...", "created": true, "has_timer": false}.
409 without ?replace=true if the job exists.
PUT /api/box/jobs/{name}
Replace the definition of an existing job. If a timer exists, its
schedule is regenerated from the new definition
(timer-create semantics on the existing unit: rewrite + reload).
The {name} in the path must equal body.name (400 otherwise).
Response 200:
{
"name": "heartbeat",
"updated": true,
"timer_reloaded": true
}
DELETE /api/box/jobs/{name}
Delete the definition. Refuses (409 TIMER_STILL_ACTIVE) if a
timer exists unless ?force=true, in which case the timer is
deleted first (same backend as DELETE /timers/{name}?keep_job=false).
Response 200: {"name": "...", "deleted": true, "timer_deleted": false}.
POST /api/box/jobs/{name}/trigger
Run the dispatcher now, outside the schedule. For testing and for chain steps.
Backend: box-ctl.py job-trigger <name> runs
job-dispatch.py <name> on bl. The API server runs it in the
background and returns immediately — dispatch involves browser
automation that can take 30–60s; the HTTP request must not block.
Response 202:
{
"name": "heartbeat",
"job_id": "heartbeat-20261004-034737-63b57a71",
"triggered": true,
"status_url": "/api/box/requests/<request_id>"
}
The job_id is minted by the API server (same format as the
dispatcher: {name}-{YYYYMMDD-HHMMSS}-{8hex}) and passed to the
dispatcher via a --job-id flag (dispatcher change required; until
then the response omits job_id and only returns request_id).
Completion is tracked through the confirmation system (§8): the
trigger creates a request of type job_trigger, and the
[RESULT job_id] collector marks it confirmed.
Errors: 404 (NOT_FOUND — no job definition), 502.
5.3 Requests ([REQ]/[CONFIRM])
Side-effecting API calls (§5.1–5.2 mutations, trigger) create a
request record. The server notifies the responsible agent with a
[REQ {request_id}] DM; the agent's [CONFIRM {request_id}] reply
(or the JOB system's [RESULT {job_id}]) closes the loop. This is
the generalization of [JOB]→[RESULT] from BOX-API-SPEC.md.
Request record
{
"request_id": "a1b2c3d4",
"type": "timer_create",
"status": "pending",
"actor": "operator-main",
"tier": "agent",
"job_name": "board-watch",
"created_at": "2026-10-04T04:00:00Z",
"confirmed_at": null,
"attempts": 1,
"timeout_s": 300,
"detail": { "unit": "job-board-watch.timer" },
"confirm_payload": null
}
type:timer_create|timer_delete|timer_control|job_create|job_update|job_delete|job_trigger|generic.status:pending→confirmed|failed|timeout.- Store:
/srv/box/requests.jsonl(append-only log) plus a companion index/srv/box/requests-index.json({request_id → latest status}) rebuilt from the log on startup. The log is the source of truth; the index is a cache.
POST /api/box/requests
Create a generic confirmation-tracked request (for flows the typed
endpoints don't cover). The server sends the [REQ] DM immediately.
Request:
{
"type": "generic",
"title": "Rotate webhook secret",
"assignee": "opm",
"body": "Rotate the X secret and confirm.",
"timeout_s": 600
}
Response 201:
{
"request_id": "f7e8d9c0",
"status": "pending",
"dm_sent": true,
"status_url": "/api/box/requests/f7e8d9c0"
}
GET /api/box/requests
List requests, newest first. Query params:
status=pending|confirmed|failed|timeout,
actor=<name>, type=<type>, limit (default 20, max 100).
Response 200:
{
"requests": [
{
"request_id": "a1b2c3d4",
"type": "timer_create",
"status": "confirmed",
"actor": "operator-main",
"job_name": "board-watch",
"created_at": "2026-10-04T04:00:00Z",
"confirmed_at": "2026-10-04T04:00:22Z"
}
]
}
Agents with box-signatures see only requests where they are the actor or the assignee (privacy: one agent's pending work is not another's business). Operators and super see all.
GET /api/box/requests/{request_id}
Response 200: the full record (§5.3), including
confirm_payload when confirmed:
{
"request_id": "a1b2c3d4",
"status": "confirmed",
"confirm_payload": {
"text": "Timer job-board-watch.timer created and active.",
"at": "2026-10-04T04:00:22Z"
}
}
Errors: 404 (NOT_FOUND), 403 (agent requesting
another agent's request).
6. Job definition JSON schema (canonical)
This is the contract for POST /timers, POST /jobs,
PUT /jobs/{name}, and what GET /jobs/{name} returns.
{
"name": "board-watch",
"description": "Check board for new posts every 5 minutes",
"schedule": "*/5 * * * *",
"scheduler": "systemd",
"agent": "muse",
"prompt_template": "Check the board for posts since {last_run}. ...",
"timeout": 300,
"on_failure": "alert",
"chain_next": null,
"sidechat": {
"create": true,
"name_template": "job-{job_name}-{date}",
"reuse_key": "board-watch-muse"
}
}
| Field | Required | Rules |
|---|---|---|
name |
✅ | ^[a-z0-9-]{1,64}$. Immutable after creation. |
description |
❌ | ≤ 280 chars. |
schedule |
✅ | Cron M H dom mon dow. Each field validated (ranges, steps, lists). Must convert to a valid systemd OnCalendar (§7). |
scheduler |
❌ | Default "systemd". v1 accepts only "systemd"; "cron"/"at" return 400 UNSUPPORTED_SCHEDULER (appendix A of BOX-API-SPEC.md is v2). |
agent |
✅ | ∈ {"muse","pip","646","opm"} — must be a node in the machine registry with a healthy chromebox. |
prompt_template |
✅ | 1–4000 chars. May use {job_id}, {job_name}, {datetime}, {date}, {last_run}. Unknown {placeholders} → 400. Must not contain [REQ/[CONFIRM/[JOB/[RESULT literals (those are protocol framing; the dispatcher adds them). |
timeout |
❌ | Default 300. Integer, 60–3600. |
on_failure |
❌ | Default "alert". ∈ {"retry","alert","ignore"}. |
chain_next |
❌ | Default null. If set, must name an existing job (400 otherwise; prevents dangling chains). |
sidechat.create |
❌ | Default false. Boolean. |
sidechat.name_template |
❌ | Only meaningful if create is true. ≤ 120 chars; allowed placeholders {job_name}, {date}, {job_id}. |
sidechat.reuse_key |
❌ | ^[a-z0-9-]{1,64}$. Enables the job-sidechats.json UUID mapping so recurring jobs reuse one thread instead of spawning one per run. Recommended for every recurring job. |
Validation happens twice: in the API server (fast, user-friendly
400s) and in box-ctl.py job-put (authoritative — §4.3).
7. Cron → systemd OnCalendar conversion
The helper converts the cron schedule to OnCalendar= when
generating the .timer unit. v1 supports the patterns jobs
actually use; anything else is rejected with a clear error
rather than silently mis-scheduled.
| Cron | OnCalendar |
|---|---|
*/5 * * * * |
*:0/5 |
*/15 * * * * |
*:0/15 |
0 * * * * |
hourly |
0 */2 * * * |
*:0/120 (see note) |
30 2 * * * |
02:30 |
0 9 * * 1 |
Mon 09:00 |
- Conversion is done by a small pure function in
box-ctl.py(no croniter — not installed on bl, and adding a dependency for this is overkill). - The generated string is validated with
systemd-analyze calendar "<expr>" --iterations=2; nonzero exit →400 INVALID_SCHEDULE. - Note on
*/2hours: systemdOnCalendarsupports/repetition on the hour field (0/2:00); the converter emits that form. Persistent=trueis always set (catch-up after downtime — matches the existing heartbeat timer).AccuracySec=1mindefault (no need for sub-minute precision; reduces wakeups).
8. Confirmation flow mechanics
8.1 Who sends the [REQ] DM
The API server (VM), via a new box-ctl action or via the existing DM path. Two options; the design picks (a):
- (a) The helper sends it. New action
box-ctl.py notify <agent> <request_id> <type>: renders[REQ {request_id}] {title}\n{body}\nReply with [CONFIRM {request_id}] ...and sends viadm.pyon bl (it is already there, next to the dispatcher). Keeps DM-sending on bl where the chromeboxes live. - (b) The VM sends via a future
/api/box/dmsendpoint. Deferred.
The [REQ] DM goes to the assignee: for typed requests, the
agent that owns the job (job.agent); for generic requests, the
assignee field. If the request was made by an agent about its own
job, the [REQ] still goes out — the confirmation is about the
action completing on bl, not about the requester's intent.
8.2 Who sends [CONFIRM]
- For
timer_create/delete/control,job_create/update/delete: the confirmation is server-side and automatic. The helper already verified the outcome (unit active, file written). The request is markedconfirmedwhen the helper reports success — no agent round-trip needed. The[REQ]DM is then informational ("this happened"), and the request record is the audit trail. Rationale: the agent being asked to confirm is often the same agent whose chromebox just did the work; a human-style ack adds latency without signal. The spec's "DMs must result in confirmations" is satisfied by the verified helper result + audit log. - For
job_triggerandPOST /requests(generic): confirmation is agent-driven. The assignee replies[CONFIRM {request_id}](or[RESULT {job_id}]for triggers, which the result collector translates). The pending-request sweeper (§8.3) handles silence.
This split keeps the common path fast (verified writes need no chat round-trip) while keeping genuinely asynchronous work (job execution) explicitly confirmed.
8.3 Pending-request sweeper
A systemd timer on the VM (box-request-sweeper, every 60s) scans
requests-index.json for pending records where
now - created_at > timeout_s (default 300s):
- If
attempts < 3: re-send the[REQ]DM (attempts += 1, logrequest_retry). - Else: mark
timeout, logrequest_timeout, and — if the request type isjob_trigger— apply the job'son_failurepolicy (alert → DM to#operators/ the requesting actor).
The sweeper only touches records it owns (VM-side); it never calls bl.
8.4 Reading confirmations
The result collector (JOB-SPEC.md §5, on bl) already watches for
[RESULT {job_id}]. It gains a second pattern: when a
[CONFIRM {request_id}] DM is seen in dm-log.jsonl, it appends
to a confirmations.jsonl which the VM sweeper (or a
box-ctl.py confirm-pull action) ships back to /srv/box/.
Simpler v1 alternative: the collector POSTs directly to the VM's
POST /api/box/requests/{id}/confirm (box-signed, operator
allowlist) — one new endpoint, no file shipping:
POST /api/box/requests/a1b2c3d4/confirm
Content-Type: application/json
{ "ok": true, "text": "Timer job-board-watch.timer created and active." }
Response 200: {"request_id": "...", "status": "confirmed"}.
ok: false marks failed with the text as reason. This endpoint
is allowlisted to the same identities as mutations (§3.1).
9. Security details
- No arbitrary command execution. The API server's bl access
is exactly:
ssh … box-ctl.py <allowlisted verb> <validated name>. There is no endpoint that passes a shell string, noExecStartinterpolation, nosystemctlsubcommand passthrough. - Two-layer validation. API server validates for UX (400s);
box-ctl.pyre-validates as the trust boundary (§4.3). The helper does not trust the API server. - Name regex everywhere.
^[a-z0-9-]{1,64}$on job names, reuse keys, and request IDs (server-generated hex for the latter). No path traversal, no unit-name smuggling (job-../../xis impossible). - Prompt templates are data. They are stored in JSON and rendered by the dispatcher with a fixed placeholder set. They never reach a shell, a unit file, or SQL.
- Git auditability preserved.
job-put/job-deletecommit on bl with author<actor> via box-api.git logon the jobs dir remains the definition history (JOB-SPEC.md § Security). - Rate limiting. 10 mutations/min/agent (token bucket,
shared
rate_limiter.pysemantics ported to the API server). Reads: 60/min/agent.429withRetry-After. - Dry-run.
?dry_run=trueon all mutations: runs validation- helper in check mode, returns the plan, changes nothing.
- Idempotency.
Idempotency-Keyon POST (§5); safe retries for flaky mobile clients. - Audit everything. Every mutation →
/srv/box/audit.jsonlvia_box_auditwith actor, tier, action, name, request_id, and result. Reads of timer/job data are audit-logged too (existing_box_apibehavior). - Secrets never in responses. Job definitions contain no credentials by construction (dispatcher handles none). DM wire metadata is returned without signature blocks unless the caller is super/operator (signatures are verification material, not secret, but redaction is cheap).
- Bl unreachable ≠ silent. SSH failures return
502 BL_UNREACHABLEwith adetail; mutations are never half-reported as success.
10. Interaction diagram
Agent / human UI
│ HTTPS + auth (§3)
▼
box.muse-dev.online → Caddy → board/server.py::_box_api (VM :8090)
│ validate, authz, audit, rate-limit
│ SSH: box-ctl.py <verb> <name> (mutations)
▼
bl: bin/box-ctl.py (allowlist + re-validate)
├── jobs/<name>.json (job-put/get/delete, git-committed)
├── ~/.config/systemd/user/job-<name>.{service,timer}
│ │ daemon-reload / enable / start / ...
│ ▼
│ systemd user manager
│ │ OnCalendar fires
│ ▼
│ job-dispatch.py <name> → dm.py / sidechat send
│ │ [JOB job_id] DM to agent
│ ▼
│ job-log.jsonl, job-sidechats.json
│
└── [REQ]/notify path: dm.py → agent chromebox
Reads (GET /timers, GET /jobs) follow the same SSH path with
read-only verbs; nothing on bl is modified.
11. Error codes
| HTTP | code |
Meaning |
|---|---|---|
| 400 | INVALID_JOB |
schema validation failed (detail.field, detail.reason) |
| 400 | INVALID_SCHEDULE |
cron didn't parse or convert |
| 400 | UNSUPPORTED_SCHEDULER |
scheduler ≠ "systemd" |
| 400 | NAME_MISMATCH |
path name ≠ body name |
| 400 | BAD_NAME |
name fails ^[a-z0-9-]{1,64}$ |
| 403 | FORBIDDEN |
auth missing/insufficient; agent not in mutate allowlist |
| 404 | NOT_FOUND |
timer / job / request unknown |
| 409 | ALREADY_EXISTS |
job or timer already present |
| 409 | TIMER_STILL_ACTIVE |
job delete while timer exists (use ?force=true) |
| 429 | RATE_LIMITED |
too many ops (Retry-After header) |
| 502 | BL_UNREACHABLE |
SSH to bl failed |
| 502 | TIMER_CREATE_FAILED |
job written but unit creation failed |
| 502 | DISPATCH_FAILED |
trigger ran but dispatcher errored |
All error bodies: {"error": "<human text>", "code": "<CODE>", "detail": {...?}}.
12. What changes in existing components
bin/box-ctl.py(new, on bl). The allowlisted helper (§4). Lives in the NetVM repo, deployed to bl with everything else.bin/job-dispatch.py(small change). Accept--job-idsoPOST /jobs/{name}/triggercan mint the ID up front and return it in the202(§5.2). Backward compatible (flag optional).- Result collector (small change). Recognize
[CONFIRM {request_id}]and forward toPOST /api/box/requests/{id}/confirm(§8.4). /srv/board/server.py(VM). New_box_apibranches for/timers,/jobs,/requests;_box_auditcalls; requests store (/srv/box/requests.jsonl+ index); idempotency store; the 60s sweeper timer unit on the VM.- Box web UI. Timer/job/request pages call these endpoints (thin client; "UI for Creativity, API for Steering" — every UI action shows its curl equivalent).
Nothing changes in dm.py, muse-chat-api.py, or the systemd
units of existing jobs.
13. Open questions (for the human, not blockers)
- Should
POST /timerssend the informational[REQ]DM at all, given §8.2 makes creation confirmations automatic? (Lean: no DM for auto-confirmed actions; the audit log + request record is the trail. The UI/API response already tells the caller.) chain_nextvalidation requires the target job to exist — should creation order then force topological creation, or should danglingchain_nextbe allowed with a warning? (Lean:400, explicit is better than dangling.)- The
agentallowlist (muse/pip/646/opm) is hardcoded in §6. Should it be read live from the machine registry instead? (Lean: yes at implementation time — one source of truth.) - v2 schedulers (
cron,at): still wanted, or is systemd sufficient for the fleet's needs?
14. Relationship to prior specs
BOX-API-SPEC.md— background and appendices (DM metadata, board/chat APIs, confirmation philosophy). Its endpoint sketches are superseded by §5 here; its auth, rate-limit, and audit sections are adopted unchanged.JOB-SPEC.md— the job YAML/JSON format, dispatcher, collector. This doc defines the API that manages those artifacts; the job schema in §6 is the JSON rendering of JOB-SPEC.md's YAML.DM-SPEC.md/DM_SPEC.md—[from:X] [id:Y]attribution and signing.[REQ]/[CONFIRM]DMs reuse that envelope.