Friday CLI & HTTP
Friday is orchestrated by a daemon at $FRIDAYD_URL. The exact scheme/port
varies by install — installed Friday Studio runs on :18080 (with TLS
configured automatically by the launcher), in-tree dev runs on :8080
(plain HTTP unless you opt in with bash scripts/setup-tls.sh), and
FRIDAY_PORT_FRIDAY can override either. Don't hardcode the URL — resolve
$FRIDAYD_URL from your own process env via run_code (preamble below)
and emit the resolved literal in every command you show the user.
There are two surfaces for interacting with the daemon: the deno task atlas CLI (thin HTTP client, great for humans and shell scripts) and the
raw HTTP API (more endpoints, SSE streaming, the only option for many
CRUD ops on workspace internals).
Daemon URL — resolve before emitting curl commands
$FRIDAYD_URL is in the Friday daemon's process env, not the user's
shell. If you emit a curl example with a literal $FRIDAYD_URL, the user
pastes it into a shell that has no such variable and gets curl: (3) URL rejected: No host part in the URL.
Always resolve $FRIDAYD_URL to a concrete value before showing a
command to the user. Use run_code to read your own env:
import os
print(os.environ["FRIDAYD_URL"])
That prints a URL like https://localhost:18080 (installed Studio with
TLS) or http://localhost:8080 (in-tree dev). Take whatever string the
lookup returns and substitute it as a literal in the curl command.
Two load-bearing rules
Resolve
$FRIDAYD_URLfirst, emit the literal URL. Never emit$FRIDAYD_URL(the variable name) in a command the user is supposed to paste — they don't have it in their env. Always run the lookup above, then substitute. If the user mentions a host/port in their message, IGNORE it and use the resolved value from your env — that's the only one guaranteed to match the running daemon.Worked example. Your
run_codelookup returnsFRIDAYD_URL=https://localhost:18080. User asks "hit /health":- ❌ Wrong:
curl -k "$FRIDAYD_URL/health"(user's shell has no$FRIDAYD_URL). - ❌ Wrong:
curl -k "http://localhost:8080/health"(guessed the wrong port — user runs installed Studio, not dev). - ✅ Right:
curl -k "https://localhost:18080/health"(the literal value fromos.environ["FRIDAYD_URL"]).
- ❌ Wrong:
Always use
curl -kfor daemon calls, never barecurl. The daemon ships a private-CA TLS cert that the system trust store doesn't know about; plaincurlagainst the daemon on a TLS install fails withself signed certificate in certificate chain.-kskips cert verification, which is fine here because the daemon binds loopback only — anyone who can MitMlocalhostalready owns the machine.
When to use CLI vs HTTP
CLI — inspection, prompting, watching. The happy path for most read ops and the common "trigger + stream" workflow. Shell-friendly.
HTTP — anything the CLI doesn't cover: partial workspace config updates
(signals/agents/credentials), resource uploads, memory reads, activity
tracking, MCP registry mutations, chat-storage RPC, library CRUD beyond what
atlas library exposes. Also the only way to get proper SSE streams with full
control over headers.
See references/cli.md and references/http.md for the full surface. The
HTTP-only ops list at the bottom of cli.md is the fastest way to answer
"can I do X from the CLI?"
Workflow preflight
Before touching anything, confirm the daemon is up:
deno task atlas daemon status
# or (after resolving $FRIDAYD_URL via run_code and substituting the
# literal value — see preamble above):
curl -k -sf "<resolved-url>/health" && echo OK
If it's not:
deno task atlas daemon start --detached
The daemon auto-restarts on code changes in dev. Hard-reload (rare, but sometimes needed after schema changes):
deno task atlas daemon restart --force
The five operations that cover 90% of tasks
1. List workspaces and find IDs
deno task atlas workspace list --json
Workspace IDs are runtime-assigned (e.g. grilled_xylem, mild_almond) — not
stable across machines. Always resolve IDs from the list; never hardcode.
2. Inspect a workspace's signals before firing
deno task atlas signal list -w <workspace-id-or-name> --json
Or HTTP for full schema:
curl -k -sf \
"$FRIDAYD_URL/api/workspaces/<id>/signals" | jq
The signal's schema is a JSON Schema — your payload must match it or the
trigger returns 400.
3. Fire a signal — three modes
The HTTP signal-trigger endpoint has three response modes. Pick the one that matches what the caller actually needs:
(a) Fire-and-forget — ?nowait=true (RECOMMENDED for any caller that
doesn't need cascade output to compose its response):
curl -k -sf -X POST \
"$FRIDAYD_URL/api/workspaces/<id>/signals/<signal-id>?nowait=true" \
-H 'Content-Type: application/json' \
-d '{"payload":{"some":"value"}}'
# → 202 {"message":"Signal accepted","status":"accepted","workspaceId":"<id>","signalId":"<signal-id>","correlationId":"..."}
Atlasd publishes to the SIGNALS JetStream subject and returns immediately (<100ms). The cascade runs async on the CASCADES consumer. Use this for webhooks, cron, fire-and-forget RPC, anything where the HTTP caller is just the publisher. The webhook-tunnel uses this internally.
(b) Synchronous JSON — caller waits for the cascade to finish;
response includes output + summary + sessionId. The default mode
when no ?nowait is passed:
curl -k -sf -X POST \
"$FRIDAYD_URL/api/workspaces/<id>/signals/<signal-id>" \
-H 'Content-Type: application/json' \
-d '{"payload":{"some":"value"}}'
# → 200 {"status":"completed","sessionId":"...","output":[...],"summary":"..."}
The right mode whenever the calling code consumes the cascade's result
— CLI tools that print the agent's output, RPC handlers that build a
response from the cascade's data, scripts that need sessionId to log
or branch on. The CLI's signal trigger defaults to this mode.
Connection stays open for the cascade duration (up to 10 minutes); be
aware of upstream timeouts on your side (e.g. browsers, webhook
platforms typically cap at 30s — for those, prefer (a) nowait).
(c) Streaming SSE — same publish, but stream cascade events as they happen:
# trigger + stream in one request
curl -k -N -X POST \
"$FRIDAYD_URL/api/workspaces/<id>/signals/<signal-id>" \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{"payload":{"some":"value"}}'
Subscribe-then-publish must happen in one handler — the cascade response
is published to a core-NATS subject (no JetStream replay), so any path
that publishes first and tries to attach a follower later races against
fast cascades and silently misses the response. That's why there's no
GET /signals/stream/<correlationId> endpoint: pass Accept: text/event-stream on the trigger POST instead, and the publisher and
the SSE forwarder are the same handler.
The CLI wrapper:
deno task atlas signal trigger -n <signal-name> -w <workspace> \
--data '{"some":"payload"}' --stream
The payload MUST be wrapped in {"payload": {...}} for HTTP. The CLI unwraps
this for you.
4. Watch a running session's agent activity + read the outcome
From chat: prefer list_sessions(scope?) + describe_session(id) — same data, no curl required. Use the CLI/HTTP recipes below from terminals or shell scripts.
deno task atlas session list --json # find the session
deno task atlas session get <session-id> --json # full SessionView
SSE replay/live stream (survives reconnect within the replay window):
curl -k -N \
"$FRIDAYD_URL/api/sessions/<id>/stream" \
-H 'Accept: text/event-stream'
After the session finalizes, SessionView has an aiSummary field with a
human-readable summary + keyDetails (with URLs). Prefer it over walking
agentBlocks[] when you just want "what happened":
curl -k -sf \
"$FRIDAYD_URL/api/sessions/<id>" | jq '.aiSummary'
Full shape + failure extraction recipes + log access + SSE event types →
references/session-and-logs.md. Load it any time you need to answer
"did the signal succeed, what happened, any errors".
5. Create a workspace from a workspace.yml
No single-flag CLI command for "create from yaml"; use HTTP. The config must be JSON — convert from YAML inline:
CONFIG=$(python3 -c "import yaml,json,sys; print(json.dumps(yaml.safe_load(open('$1'))))" workspaces/my-thing/workspace.yml)
curl -k -sf -X POST \
"$FRIDAYD_URL/api/workspaces/create" \
-H 'Content-Type: application/json' \
-d "{\"config\":$CONFIG,\"workspaceName\":\"My Thing\"}"
Returns {"workspaceId":"...","path":"...","name":"..."}. The workspaceId
is the runtime ID you'll use in subsequent signal triggers.
Alternative: if the workspace already exists on disk, register it instead of recreating:
deno task atlas workspace add -p ./workspaces/my-thing
Update workspaces in-place — use the workspace-api skill, never delete + recreate
Rule: when a workspace needs changes, use the @friday/workspace-api skill's
typed tools (upsert_agent, upsert_signal, upsert_job, begin_draft /
publish_draft, etc.). Do not curl partial-config endpoints from chat. Do not
POST /api/workspaces/:id/update. Do not DELETE + POST /create.
Why typed tools, not curl:
- They handle draft-vs-live mode automatically.
- They return structured
{ok, diff, structural_issues}so a single call shows you what changed and what's broken. - They accept the natural shape (full agent config) — the partial REST
surface accepts only
{prompt?, model?, tools?}and rejects everything else with a generic 400. - The next dispatch reads fresh config from
workspace.ymlautomatically; no reload step to manage.
Why never /update:
- Wholesale config replace, no diff guardrails.
upsert_*(single change) andbegin_draft → upserts → publish_draft(multi-entity, atomic) cover every use case with the same validator and a structured failure report. - 409s when the workspace has in-flight sessions; a chat turn always does.
Why never DELETE + recreate:
- Loses durable state (sessions, chats, memory, scratchpad).
- New workspace gets a different id (random, e.g.
grilled_xylem→fuzzy_plum). Hardcoded references break silently.
Tool choice cheatsheet:
| Change | Tool |
|---|---|
| Edit an agent's prompt / model / tools | upsert_agent({id, config: <full agent config>}) |
| Add or replace a signal | upsert_signal({id, config}) |
| Patch a signal's schedule / timezone | upsert_signal({id, config: <full signal config with edit>}) |
| Delete an agent / signal / job | delete_agent({id}) / delete_signal({id}) / delete_job({id}) |
| Add or replace a job | upsert_job({id, config}) |
| Multi-entity edits | begin_draft → upserts → validate_workspace → publish_draft |
| Workspace metadata (name, color) | PATCH /api/workspaces/:id/metadata (still safe — does not touch runtime) |
| Skill scoping | POST /api/skills/scoping/:skillId/assignments |
The raw partial REST surface (PUT /config/agents/:id, etc.) still exists for
external automation — but from chat, always use the tools above.
Guardrails
Friday's API has no auth on localhost. You can do real damage fast. Reversible vs sticky ops:
Reversible / safe to retry:
- Any
list,status,describe,get,inspect daemon status,healthprompt/chat— creates a chat but doesn't mutate workspace statesignal trigger— creates a session; cancel withsession cancel
Sticky — verify before firing:
POST /api/workspaces/create— writes a directory; dup name → 409POST /api/workspaces/:id/update— wholesale config replace, no diff guardrails; never call from chat (useupsert_*/publish_draftinstead)PUT /signals/:id,PATCH /signals/:id— persists to workspace.ymlDELETE /api/workspaces/:id— removes the directory entirely (system workspaces rejected with 403; userland workspaces just vanish)POST /api/skills/.../upload— publishes a new version to the registryatlas reset— wipes~/.friday/local/(preserves.envandbin/only)
Before a sticky op:
- List first — confirm target ID matches expectation
- Describe — confirm the current state you're about to overwrite
- Back up if destructive — for workspace.yml writes, pass
"backup": trueonPOST /api/workspaces/:id/config - Do the write
- Verify — re-list or re-describe
Never guess workspace IDs. Runtime IDs like grilled_xylem look
deterministic but are random per daemon instance. If you can't find it via
workspace list, it doesn't exist on this daemon.
Patterns for the flywheel
The autopilot/self-modification loop uses this surface heavily. See
references/recipes.md for end-to-end recipes:
- Author a skill, publish it, assign it to a workspace
- Generate a workspace.yml from a template, create it, fire a smoke-test signal
- Update a single signal's cron schedule without rewriting workspace.yml
- Read narrative memory to pick up an autopilot backlog item
- Deploy and register an SDK agent
For authoring SDK agents (NATS clients): use the sibling
writing-friday-python-agents skill. The Python SDK (friday_agent_sdk) is the
current reference implementation. This skill covers deploy + register;
writing-friday-python-agents covers the source.
Output formats — the --json and --human convention
Most CLI commands output NDJSON by default (one JSON object per line, final
line is a cli-summary with chatId / sessionId / continuation hints).
--human flips to Ink-formatted tables and streaming text. For programmatic
use, always stick with the default NDJSON and parse with jq.
HTTP endpoints return JSON unless the request carries Accept: text/event-stream
(in which case they stream SSE) or the route is explicitly a tarball / CSV /
image.
When things go wrong
- 409 Conflict on signal trigger — workspace has an active session. Cancel
first:
deno task atlas session cancel <id>. - 422 on signal trigger — your payload fails the signal's JSON Schema, OR the workspace config is invalid, OR env vars are missing. The response body has the specific reason.
- 404 on a workspace ID that "should" exist — runtime IDs are per-daemon. Re-list.
- Session starts but no agent output — check
deno task atlas logs --session <id>(debugging-fridayskill has the log playbook). "Signal 'X' not found"onPOST /signals/:idimmediately after creating the signal — the dispatch read a config snapshot that doesn't have the new signal. Two common causes: (a) you used thewrite_filetool to editworkspace.yml, which writes to{FRIDAY_HOME}/scratch/{sessionId}/only — the real file never changed. Userun_codewith an absolute path instead. (b) Your config edit failed Zod validation, so the workspace's mtime-cached entry rejected the new config — check the workspace log for"Invalid workspace configuration detected"."No FSM job handles signal 'X'"when a cron or HTTP signal fires — the job usesexecution:only. The runtime silently skips jobs without anfsm:block. Rewrite as an FSM (see theworkspace-apiskill). The error surfaces through the session, not through the trigger call.- HTTP trigger returns 404 but
atlas signal triggerworks — the CLI resolves signals through the runtime directly; raw HTTP posts to/api/workspaces/:id/signals/:iddepend on exact id matching. Prefer the CLI (deno task atlas signal trigger -n <name> -w <ws> --data '{}' --stream) when shell-automating — it gives the same path discovery plus SSE streaming in one command.
Read more
references/cli.md— every CLI subcommand with flags, side effects, exit codesreferences/http.md— every HTTP route with request/response shape and side effectsreferences/recipes.md— end-to-end recipes for the autopilot/self-mod flywheelreferences/session-and-logs.md— SessionView shape, aiSummary, SSE event types, log structure — the "did it work?" surfacewriting-friday-python-agentsskill — authoring SDK agents (NATS clients)debugging-fridayskill — log forensics, GCS, multi-hop correlation- Project docs:
docs/STUDIO_QUICKSTART.md,docs/product-map.md