11 KiB
BOX-API-SPEC.md: Agentic Timer Management Interface
Overview
The box page (https://box.muse-dev.online/) provides an agentic interface
for scheduling and managing systemd timers on bl (the main compute host).
Agents and humans use the same REST API; the web UI is a thin client over it.
Principle: Timers are jobs. The box API is the control plane for the JOB system (see JOB-SPEC.md). Agents can list, create, and manage timers without SSH access to bl.
Authentication
Same ops auth as the box page:
- Bearer token:
Authorization: Bearer <operator-token>(for agents) - PIN session: Cookie from
/api/box/login(for humans via UI) - Agent signatures: Box-signed requests (for automated agents)
All endpoints require ops-level auth. Anonymous requests get 403. Public users see only the PIN login page, never timer data.
Base URL
https://box.muse-dev.online/api/box
Endpoints
List Timers
GET /api/box/timers
Returns all systemd user timers on bl.
Response (200):
{
"timers": [
{
"name": "job-board-watch.timer",
"active": true,
"enabled": true,
"last_run": "2026-10-04T02:30:00Z",
"next_run": "2026-10-04T02:35:00Z",
"job_name": "board-watch"
}
]
}
Get Timer Status
GET /api/box/timers/{name}
Response (200):
{
"name": "job-board-watch.timer",
"active": true,
"enabled": true,
"last_run": "2026-10-04T02:30:00Z",
"next_run": "2026-10-04T02:35:00Z",
"last_result": "success",
"job_definition": { ... } // parsed YAML
}
Errors:
404: Timer not found
Create Timer (and Job)
POST /api/box/timers
Content-Type: application/yaml
Body is a Job YAML definition (see JOB-SPEC.md).
Example body:
name: board-watch
schedule: "*/5 * * * *"
agent: muse
prompt_template: "Check board..."
timeout: 300
Response (201):
{
"name": "job-board-watch.timer",
"job_name": "board-watch",
"created": true
}
Errors:
400: Invalid YAML or missing required fields409: Timer already exists
Backend actions:
- Write
/home/super/Projects/NetVM/jobs/<name>.yaml - Generate systemd
.serviceand.timerunits systemctl --user daemon-reloadsystemctl --user enable --now job-<name>.timer
Control Timer
POST /api/box/timers/{name}/start
POST /api/box/timers/{name}/stop
POST /api/box/timers/{name}/enable
POST /api/box/timers/{name}/disable
POST /api/box/timers/{name}/restart
Response (200):
{
"name": "job-board-watch.timer",
"action": "start",
"success": true
}
Delete Timer
DELETE /api/box/timers/{name}
Response (200):
{
"name": "job-board-watch.timer",
"deleted": true
}
Backend actions:
systemctl --user stop job-<name>.timersystemctl --user disable job-<name>.timer- Remove unit files
systemctl --user daemon-reload- Optionally keep or delete the job YAML (query param
?keep_yaml=true)
List Jobs
GET /api/box/jobs
Returns job definitions (YAML files in /home/super/Projects/NetVM/jobs/).
Response (200):
{
"jobs": [
{
"name": "board-watch",
"schedule": "*/5 * * * *",
"agent": "muse",
"has_timer": true,
"timer_active": true
}
]
}
Get Job Definition
GET /api/box/jobs/{name}
Response (200):
{
"name": "board-watch",
"definition": { ... } // parsed YAML
}
Trigger Job Now
POST /api/box/jobs/{name}/trigger
Manually trigger a job immediately (outside its schedule). Useful for testing.
Response (200):
{
"job_name": "board-watch",
"job_id": "board-watch-20261004-023500-x1y2z3",
"triggered": true
}
Backend Implementation
The board server (on VM, port 8090) proxies to bl via SSH.
SSH path: VM → bl via tailnet (super@100.123.153.75), using VM's SSH key.
The board server runs as super on VM, which has passwordless SSH to bl.
Command mapping:
| API | SSH command on bl |
|---|---|
| List timers | systemctl --user list-timers --no-pager --output=json |
| Get status | systemctl --user status job-{name}.timer --no-pager |
| Start | systemctl --user start job-{name}.timer |
| Stop | systemctl --user stop job-{name}.timer |
| Enable | systemctl --user enable job-{name}.timer |
| Disable | systemctl --user disable job-{name}.timer |
| Create | Write YAML + units, daemon-reload, enable --now |
Security:
- The board server validates job YAML before writing (schema check)
- Timer names are sanitized:
^[a-z0-9-]+$only - No shell injection: use
systemctlwith fixed args, never shell-expand user input - SSH uses a restricted key (only
systemctl --usercommands allowed viaauthorized_keyscommand restriction) - All actions logged to
/srv/box/audit.jsonl
Rate Limiting
Use shared rate limiter (see bin/rate_limiter.py on bl):
- Max 10 timer operations per minute per agent
- Prevents accidental timer spam
Error Handling
All errors return JSON:
{
"error": "Timer not found",
"code": "NOT_FOUND"
}
Codes:
NOT_FOUND(404)INVALID_YAML(400)ALREADY_EXISTS(409)SSH_ERROR(502) — bl unreachableFORBIDDEN(403) — insufficient auth
Agentic Usage Examples
Python (using requests):
import requests
TOKEN = "opm-bearer-token-here"
BASE = "https://box.muse-dev.online/api/box"
H = {"Authorization": f"Bearer {TOKEN}"}
# List timers
r = requests.get(f"{BASE}/timers", headers=H)
print(r.json())
# Create job
job_yaml = open("myjob.yaml").read()
r = requests.post(f"{BASE}/timers", headers={**H, "Content-Type": "application/yaml"},
data=job_yaml)
print(r.json())
# Trigger now
r = requests.post(f"{BASE}/jobs/myjob/trigger", headers=H)
print(r.json())
Bash (curl):
TOKEN="opm-bearer-token-here"
# List
curl -H "Authorization: Bearer $TOKEN" \
https://box.muse-dev.online/api/box/timers
# Create
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/yaml" \
--data-binary @myjob.yaml \
https://box.muse-dev.online/api/box/timers
Web UI
The human UI at https://box.muse-dev.online/ provides:
- Timer list with status indicators (active/inactive, last run)
- Create form (YAML editor with validation)
- Start/stop/enable/disable buttons
- Job definition viewer
- Manual trigger button
- Log viewer (job-log.jsonl, sidechat-log.jsonl)
The UI uses the same API endpoints as agents. No separate backend.
Relationship to Other Specs
- JOB-SPEC.md: Defines job YAML format, dispatcher, agent handler. This spec defines the API to manage those jobs.
- DM-SPEC.md: DMs are the transport for job prompts. This spec manages the schedulers that send those DMs.
- SIDECHAT_SPEC.md: Jobs can create side chats. This API can list which side chats belong to which jobs.
Status
Spec v0.1. Owner: operator-main. Sanctioned by the human 2026-10-04. Implementation pending.
Appendix A: Rich Linux Scheduling
The box API supports multiple Linux scheduling mechanisms, not just systemd timers. Each has tradeoffs; the API abstracts them behind a unified interface.
Scheduler Types
| Type | Use case | Precision | Persistence |
|---|---|---|---|
systemd |
Recurring jobs, services | 1s | Yes (enabled) |
cron |
Simple recurring, legacy | 1m | Yes (crontab) |
at |
One-time future jobs | 1m | No (one-shot) |
Unified Scheduler API
The /api/box/timers endpoints accept a scheduler field:
{
"name": "board-watch",
"scheduler": "systemd", // or "cron", "at"
"schedule": "*/5 * * * *",
...
}
Systemd (default): As described in main spec. Best for most jobs.
Cron: For simple recurring tasks. The API manages the user's crontab.
# Backend: (crontab -l ; echo "*/5 * * * * /path/to/job.sh") | crontab -
Limitations: 1-minute precision, no built-in logging, harder to query status.
At: For one-time delayed execution.
# Backend: echo "/path/to/job.sh" | at now + 1 hour
Use for: "remind me in 2 hours", "retry this job once in 30m".
Scheduler-Specific Endpoints
GET /api/box/schedulers # List available schedulers
GET /api/box/schedulers/{type}/jobs # List jobs for a scheduler
Appendix B: DM + Metadata JSON
DMs carry structured metadata, not just text. The box API returns DMs with full metadata for programmatic processing.
DM Wire Format
Every DM has two layers:
1. Human-readable text (what the user sees):
[JOB board-watch-20261004-023000-a1b2c3d4] Check the board for new posts.
2. Metadata JSON (for agents and API):
{
"id": "a1b2c3d4",
"job_id": "board-watch-20261004-023000-a1b2c3d4",
"type": "job",
"from": "opm",
"to": "muse",
"target": "main",
"sidechat": null,
"timestamp": "2026-10-04T02:30:00Z",
"priority": "normal",
"timeout": 300,
"chain_next": null,
"signature": "-----BEGIN SSH SIGNATURE-----\n..."
}
DM Types
| Type | Description | Required fields |
|---|---|---|
job |
Job prompt from scheduler | job_id, prompt |
result |
Job result from agent | job_id, success, output |
chat |
Regular DM | text |
alert |
System alert | severity, message |
canary |
Signature test | key_id, namespace |
API: Get DMs with Metadata
GET /api/box/dms?agent=muse&limit=20
Response:
{
"dms": [
{
"id": "a1b2c3d4",
"text": "[JOB ...] Check the board...",
"metadata": {
"type": "job",
"job_id": "board-watch-20261004-023000-a1b2c3d4",
"from": "opm",
"to": "muse",
"timestamp": "2026-10-04T02:30:00Z",
"priority": "normal"
},
"verified": true
}
]
}
API: Send DM with Metadata
POST /api/box/dms
Content-Type: application/json
Body:
{
"to": "muse",
"target": "main",
"type": "job",
"text": "Check the board...",
"metadata": {
"job_id": "board-watch-20261004-023000-a1b2c3d4",
"priority": "high",
"timeout": 300
}
}
The API constructs the wire format [JOB {job_id}] {text} and sends via
dm.py. Metadata is logged to dm-log.jsonl alongside the text.
Metadata in Logs
dm-log.jsonl entries include metadata:
{
"ts": "2026-10-04T02:30:00Z",
"type": "send",
"id": "a1b2c3d4",
"metadata": {
"job_id": "board-watch-...",
"type": "job",
"priority": "normal"
}
}
This enables:
- Filtering DMs by type, job, priority
- Building dashboards (box UI shows job DMs separately from chat)
- Audit trails (which jobs were sent, when, to whom)
- Agent learning (which job types succeed/fail)