Atelier — Operate
Manage and monitor apps running on an Atelier platform via its REST API. This
skill is for operating apps that already exist; it does not create or change
application code (see the atelier-build skill for that).
Setup
ATELIER_API_URL— base URL of the instance. Choose the address by where the agent runs:- In-cluster (the agent runs as an Atelier app, e.g. Hermes): use the
internal service address
http://atelier-core.atelier.svc.cluster.local:8080. Cluster DNS resolves it directly — no hosts-file or ingress changes, and the portal hostname (atelier.home.arpa) does not resolve inside the cluster. - External (e.g. Claude Desktop on your machine): a hostname/IP that
resolves on that machine — e.g.
http://atelier.home.arpa(add it to that machine’s hosts file / DNS) or the node IP directly.
- In-cluster (the agent runs as an Atelier app, e.g. Hermes): use the
internal service address
ATELIER_API_TOKEN— a persistent API token, sent as a bearer header. Mint one in the Atelier UI: Settings → System → API Tokens (this page is admin-only — you must be logged in as an Admin to create tokens). Pick the role the work needs: Viewer for read-only, Developer to also manage lifecycle/secrets. Tokens start withatl_.
curl -s "$ATELIER_API_URL/api/apps" -H "Authorization: Bearer $ATELIER_API_TOKEN"Responses are JSON. 401 = token missing/revoked/expired. 403 = the token’s
role is too low for that operation (see the Role column below).
Endpoint reference
Paths are relative to $ATELIER_API_URL. {name} is the app’s exact name — read
it from GET /api/apps; it often carries a random suffix (e.g. notes-app-7b8f19).
Any request with a JSON body must send Content-Type: application/json, or
the server rejects it (this includes the DELETE secrets call, which takes a body).
Inspect (role: Viewer)
| Operation | Method & path |
|---|---|
| List apps | GET /api/apps |
| Get one app (status, URL, health) | GET /api/apps/{name} — the response includes deployed_source_sha (the source commit the running build was built from — use this, not version, to tell what’s live), deployed_build_id, and ready (true once the rollout is healthy) |
| Build history / one build | GET /api/apps/{name}/builds (list, each with source_sha) and GET /api/apps/{name}/builds/{id} (one record) |
| Build logs | GET /api/apps/{name}/build-logs |
| Runtime/pod logs | GET /api/apps/{name}/logs — add ?service={service} to pick a service (e.g. backend/frontend/db); SSE, use curl -N (bound the stream with --max-time N if you only want a snapshot) |
| Resource metrics (CPU/mem) | GET /api/apps/{name}/metrics |
| Active per-app alert (if any) | GET /api/apps/{name}/alert — returns 404 when the app has no current alert |
Multi-service apps
An app can run more than one container — commonly a web service plus a database
(declared in the repo’s atelier-spec.yaml; see the atelier-build skill). When
it does:
- It has one Deployment and Service per service, named
{app}-{service}— e.g.myapp-appandmyapp-db. A single-service app just uses{app}. - Use
?service=on the logs endpoint to pick between them. When an app is unhealthy, the failing container is often not the one you assumed: an app that can’t reach its database looks like an app crash, so check the database’s logs too before concluding the app code is at fault. - Services reach each other in-cluster at
{app}-{service}:{port}. - The app’s public URL routes to one service only (a
frontendif present, else the first built one). A database is never publicly exposed. - A rebuild only rolls the services Atelier built. A stock image (a database)
is left running across a
git push, so don’t expect its pod age or restart count to change after a deploy — that’s correct, not a stale rollout.
Alerts (role: Developer)
The supervisor and reconcile passes raise alerts for things like pod crash-loops, unhealthy volumes, and registry capacity. Recent history is queryable; pass ?app=<name> to scope to one app — useful for answering “any alerts on app X lately?” without scanning the whole platform feed.
| Operation | Method & path |
|---|---|
| Recent alert history (platform-wide, newest first) | GET /api/alerts/history |
| Recent alert history for one app | GET /api/alerts/history?app={name} |
| Unread alert count | GET /api/alerts/unseen-count |
| Mark history “seen up to now” | POST /api/alerts/seen {} |
Response shape: {"alerts": [{ id, severity, title, body, dedup_key, source, raised_at, dispatched, suppressed, dispatch_error, app_name }, ...]}. The app_name field is the alert’s first-class app attribution — alerts about a specific app carry it; platform-wide alerts (registry capacity etc.) have app_name: null and are excluded by any ?app= filter.
Default limit is 50, capped at 200. Pre-existing alerts from before the app_name column was added will show app_name: null and won’t appear in per-app filters.
Lifecycle (role: Developer)
| Operation | Method & path | Body |
|---|---|---|
| Pause (scale to zero) | POST /api/apps/{name}/pause | {} |
| Resume | POST /api/apps/{name}/resume | {} |
| Redeploy (rebuild + roll, no code change) | POST /api/apps/{name}/redeploy | {} |
| Delete (archive) | DELETE /api/apps/{name} | — |
Secrets (role: Developer)
| Operation | Method & path | Body |
|---|---|---|
| List secret keys (values never returned) | GET /api/apps/{name}/secrets | — |
| Set / update secrets | PUT /api/apps/{name}/secrets | {"secrets": {"KEY": "value"}} |
| Delete secrets | DELETE /api/apps/{name}/secrets | {"keys": ["KEY1", "KEY2"]} |
Setting or deleting secrets restarts the app’s pods. Both bodies require
Content-Type: application/json.
Public access (role: Developer)
Toggles whether an app is reachable from the public internet via the platform’s Cloudflare Tunnel. When fully configured (platform tunnel credentials + a Cloudflare API token in Settings → System → Public Access), saving the toggle causes Atelier to create the Cloudflare DNS record and update the tunnel’s published-application list automatically — no manual dashboard work.
| Operation | Method & path | Body |
|---|---|---|
| Read tunnel + per-app status | GET /api/apps/{name} | — — the response includes public_hostname (string | null), public_exposed (bool), and public_url (https://<hostname> or null) |
| Platform tunnel status | GET /api/cloudflared (admin-only) | — — returns {configured, status, api_token_configured, tunnel_id_hint} |
| Toggle per-app exposure | PUT /api/apps/{name}/public-access | {"enabled": bool, "hostname": "blog.example.com" | null} |
Constraints:
hostnameis required whenenabled=trueand must be a valid DNS-1123 hostname; rejected with 400 otherwise.- The hostname must be unique across apps on this platform; conflicts return 400 with a specific message.
- CronJob apps cannot be exposed (no in-cluster Service) — 400.
- Archived apps cannot be exposed — restore first.
- Disabling preserves the hostname so the user can re-enable without
retyping; pass
{"enabled": false, "hostname": null}.
If the platform has no Cloudflare API token configured, the toggle
still persists to the DB but routes do NOT propagate automatically.
Use GET /api/cloudflared to check api_token_configured before
promising the user “it’ll be live in 10 seconds.”
After enabling, verify it actually serves — DNS + the Cloudflare edge can
be live within seconds while the tunnel route settles a beat behind:
curl -sS -o /dev/null -w '%{http_code}\n' https://<hostname>/. A 200 means
done. A 502 (especially after a ~30s hang) means the edge reached the tunnel
but the origin didn’t answer — historically an origin service/port mismatch;
if you see it persist, capture it for the platform team rather than assuming
DNS. Note GET /api/cloudflared is admin-only, so a Developer-role token
that can toggle exposure can’t read tunnel status — escalate if you need it.
Platform
| Operation | Method & path | Role | Body |
|---|---|---|---|
| Get settings | GET /api/settings | Viewer | — |
| Update settings | PUT /api/settings | Admin | partial settings object (Content-Type: application/json) |
| Ask Nova (platform assistant) | POST /api/nova (SSE) | Developer | {"messages":[{"role":"user","content":"..."}]} |
Nova returns pipe tables. Nova often answers with markdown tables. Anything downstream that doesn’t render tables (Telegram, plain-text channels, etc.) will mangle them — reformat as bullets /
**bold:** valuelines before forwarding.
Reaching deployed apps from inside the cluster
Apps deployed by Atelier — yours, Hermes’s, anything else running here — are reachable from any in-cluster pod via the apps namespace (atelier-apps), with the Service name and port depending on whether the app is single- or multi-image.
(For BYOC apps “single-image” maps to one Dockerfile at repo root; for v2-built apps it maps to a single-service AppSpec. The naming rules are the same.)
Single-image apps:
http://<app-name>.atelier-apps.svc.cluster.local ← port 80The ingress-routed Service is just <app-name> and always exposes port 80, regardless of the container’s EXPOSE. So a single-container Vite/nginx app whose EXPOSE is 8080 or 3000 is still reached on port 80 in-cluster.
Multi-image apps (BYOC: frontend/Dockerfile + backend/Dockerfile; v2: two or more services in the AppSpec):
http://<app-name>-frontend.atelier-apps.svc.cluster.local ← port 80 (the ingress-routed Service)http://<app-name>-backend.atelier-apps.svc.cluster.local ← port matches the backend's EXPOSE (typically 8000)The Service Atelier wires the ingress to (typically frontend) gets port 80; other Services keep their container EXPOSE port. So a FastAPI/Express backend with EXPOSE 8000 is reached at :8000, not :80.
Two more things that surprise agents first time:
-
Namespace is
atelier-apps, notatelier. Theateliernamespace holds Atelier’s own core/API/UI;atelier-core.atelier.svc.cluster.local:8080is the platform itself, not a user app. -
Use the explicit URL fields on
GET /api/apps/{name}. The response returns four of them — prefer these over the legacyurlfield (which is just the portal URL and is kept for back-compat):lan_url—http://<name>.<portal_domain>. Serves the app at root via a per-app subdomain. Recommended for SSR / Next.js / Vite / Astro apps that emit root-absolute URLs (/_next/...,href="/about") — the portal proxy below strips its path prefix and breaks those. Requires the operator to have configured wildcard DNS for*.<portal_domain>(e.g. via dnsmasq, Tailscale Split DNS).in_cluster_url— e.g.http://<name>.atelier-apps.svc.cluster.local:80, the address to call from inside the cluster.public_url—https://<public_hostname>when the app is exposed via Cloudflare Tunnel, elsenull.portal_url—http://<portal_domain>/apps/<name>; resolves only from machines with the hosts/DNS entry, and (being a path-stripping proxy) can break root-absolute SSR apps. Uselan_urlfor those instead.
If you’re on an older instance that doesn’t yet return
lan_urlorin_cluster_url, reconstruct them from<name>(or<name>-<service>) +<portal_domain>/atelier-apps+ the port rule above.
Common workflows
Diagnose an unhealthy app
curl -s "$ATELIER_API_URL/api/apps/$NAME" -H "Authorization: Bearer $ATELIER_API_TOKEN" # status/healthcurl -sN "$ATELIER_API_URL/api/apps/$NAME/logs" -H "Authorization: Bearer $ATELIER_API_TOKEN" # recent pod logscurl -s "$ATELIER_API_URL/api/apps/$NAME/metrics" -H "Authorization: Bearer $ATELIER_API_TOKEN" # CPU/memIf a build failed (not a running pod), read GET /api/apps/{name}/build-logs.
Rotate a secret (the app restarts automatically)
curl -s -X PUT "$ATELIER_API_URL/api/apps/$NAME/secrets" \ -H "Authorization: Bearer $ATELIER_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"secrets":{"API_KEY":"new-value"}}'Updating your local skill copy
The Atelier platform republishes latest/skills.tar.gz whenever the canonical
SKILL.md files in the repo change — typically with new gotchas, corrected
examples, or guidance learned from real sessions. The version: field in each
skill’s frontmatter bumps with every meaningful update.
Check for updates without downloading the bundle. A tiny manifest lives next
to the bundle at
https://tryatelier.blob.core.windows.net/tryatelier/latest/skills-version.json.
Poll it (it’s a few hundred bytes) and compare against the version: in your
local copies — only pull skills.tar.gz when one is ahead of yours:
{ "published": "2026-06-16T12:00:00Z", "bundle": "https://tryatelier.blob.core.windows.net/tryatelier/latest/skills.tar.gz", "skills": { "atelier-build": "1.1.0", "atelier-operate": "0.3.5" }}Cherry-pick rather than overwrite. If you have already evolved your local
skill copy (e.g. you added a Python/FastAPI Dockerfile snippet for your stack,
or annotated a section based on a build failure you debugged), a blanket
re-pull of skills.tar.gz would overwrite those local refinements.
The safer pattern: when you see a new bundle version, ask the agent that uses the skill to compare the upstream against the local copy and propose specific additions to merge in. Example prompt:
“The platform’s
atelier-buildskill is now at v0.2.8 (you’re using v0.2.6). Look at the upstream at<bundle URL>, diff against my current local copy, and propose the specific additions that are worth merging. Skip changes that conflict with my local refinements; flag those so I can resolve them by hand.”
This keeps the curation human-driven for non-trivial conflicts while picking up genuinely useful upstream additions (corrections, new gotchas, new framework snippets) automatically.
Safety
- These calls operate on a live platform.
DELETE /api/apps/{name}archives a running app andPUT .../secretsoverwrites credentials — confirm intent before destructive or credential-changing operations. - A token only grants its role’s access; a
403means the task needs a higher-scoped token (mint a new one in the UI as an Admin). - Always read the app’s real
namefromGET /api/appsrather than guessing.