Appearance
Agent API Endpoint
Overview
The Sunny Day API lets external clients invoke published agents over HTTP. All endpoints are served from https://api.sunnyday.run and require an API key.
Authentication
All requests require an Authorization: Bearer <key> header.
API keys are managed in the Sunny Day app under API Keys (/api-keys).
- Format:
sd_<32-char base64url string> - Workspace-scoped — a key can invoke any API-enabled agent in the workspace that issued it
- Shown in full once at creation; only the prefix and last 4 characters are visible afterward
- Revocable at any time
Enabling API access for an agent
API access is opt-in per agent. From the agent's Configure tab → How it runs → + Add → API endpoint.
This creates an invocation endpoint with a unique URL of the form:
POST https://api.sunnyday.run/v1/invoke/<invocation_id>The invocation can be removed at any time. Removing it permanently invalidates the URL — recreating issues a new ID.
Endpoints
POST /v1/invoke/:id
Invoke an agent and run it.
Request
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer sd_... |
Content-Type | yes | application/json for text-only inputs, or multipart/form-data when sending files |
Accept | no | text/event-stream to opt into SSE streaming (alternative to ?stream=1) |
| Query param | Notes |
|---|---|
stream=1 | Opt into SSE streaming. See Streaming below. |
JSON body (no files)
json
{
"inputs": {
"<input_name>": "<string value>"
}
}inputs is a string-to-string map keyed by the input names declared by the agent (see the agent's Inputs section in the app). Missing optional inputs use their declared default. Missing required inputs cause a 400.
The keys are the bare input names — for variables defined with in the instruction, that's name; for the manual blocks, the keys are custom_instructions and plain_text_references.
Multipart body (with files)
If the agent has the Reference Files block enabled, send multipart/form-data instead. The inputs map goes in a single text part, and each file is attached as its own part named reference_files.
| Part name | Type | Notes |
|---|---|---|
inputs | text | JSON-encoded object with the same shape as the JSON body above. Optional. |
reference_files | file | Repeat once per file. Accepts PDF, common image formats, and text/CSV/Markdown. |
Reference files are not sent via the inputs map — inputs.reference_files is rejected with a 400 telling you to use multipart parts instead.
Response (sync mode, default)
The connection blocks until the run completes. On success returns 200:
json
{
"id": "f7c2...",
"status": "completed",
"outcome": "success",
"durationMs": 4231,
"output": {
"text": "Final assistant message text, or null",
"artifacts": [
{
"id": "80e9e8f3-582b-440a-921e-ee04565ae1aa",
"filename": "report.pdf",
"contentType": "application/pdf",
"sizeBytes": 124823,
"url": "https://api.sunnyday.run/v1/artifacts/80e9e8f3-..."
}
]
}
}On failure (run started but did not finish cleanly), returns 200 with status: "errored" and an error field:
json
{
"id": "f7c2...",
"status": "errored",
"outcome": "fail",
"durationMs": 1240,
"output": { "text": null, "artifacts": [] },
"error": {
"message": "Exceeded RUN_TIMEOUT_MS",
"type": "timeout"
}
}status reports whether the run reached a terminal state cleanly — it is not an evaluation of whether the agent's output was correct. Possible values:
| Value | Meaning |
|---|---|
completed | The run finished without throwing. |
errored | The run could not finish cleanly. See error.type for the cause. |
cancelled | The run was interrupted before reaching a natural end (user-cancel or HTTP client disconnect). |
When status is "errored", error.type indicates the cause:
error.type | Meaning |
|---|---|
timeout | The run exceeded the maximum runtime budget and was aborted by the server. |
execution_error | The agent loop threw — typically a tool failure or model error. |
internal_error | A failure in the API layer before or around the run loop. Rare; safe to retry. |
orphaned_run | The run's worker exited before finishing (e.g. a server restart). Surfaced when fetching the run via GET /v1/runs/:id. |
outcome is an evaluative judgment of whether the agent achieved its assigned objectives. Unlike status (which only reflects mechanical termination), outcome is produced by an LLM-based evaluator that reads the run's task, plan, and trace.
| Value | Meaning |
|---|---|
success | The agent fully completed the requested objectives. |
partial | The agent completed some but not all objectives, or completed them with significant issues. |
fail | The agent did not meaningfully complete the objectives. |
null | No verdict available. Returned when the run was cancelled, or when the evaluator itself failed. |
The outcome adds a small amount of latency to the response (a few seconds) since the evaluator runs synchronously before the response is returned. Treat outcome as advisory — it's a heuristic judgment, not a contract.
output.artifacts is always an array (empty if none). output.text is the agent's final text message, or null if the agent produced only files.
Streaming
Pass ?stream=1 or Accept: text/event-stream to receive events via Server-Sent Events instead. The HTTP connection stays open until the run completes.
The stream opens with an accept frame, then emits periodic ping heartbeats while the run is in progress, and closes with exactly one terminal frame (completed, cancelled, or error).
| Event | When | Body |
|---|---|---|
accept | Immediately when the stream opens. | { "id": "<run id>", "timestamp": "<ISO 8601>" } |
ping | Every 15 seconds while the run is in flight. | { "timestamp": "<ISO 8601>" } |
completed | Run finished cleanly. | Same shape as the sync success body (id, status: "completed", durationMs, output). |
cancelled | Run was interrupted (user-cancel or HTTP client disconnect). | Same shape as completed, with status: "cancelled". May include partial output. |
error | Run did not finish cleanly, or the API layer itself errored before it could start. | { "id", "status": "errored", "durationMs", "output", "error": { "message", "type" } } for run-level failures; { "id", "error": { "message", "type": "internal_error" } } for pre-run failures. |
error.type values match the sync-mode table above (timeout, execution_error, internal_error).
After the terminal frame, the stream closes.
Note: Browser
EventSourcedispatches a built-inerrorevent for connection failures. To distinguish a server-emitted terminalerrorfrom a connection drop, check whetherevent.datais present — server-emitted events always have a JSON body; built-in connection errors do not.
If the client cleanly disconnects mid-stream and the server detects it, the run is cancelled — persisted with status: "cancelled", with any partial output preserved in the trace. A transport-level drop (a proxy timeout, a TLS reset, a lost network) may not be detected promptly, in which case the run keeps running to completion server-side. Either way, you can recover the run's outcome by reconnecting with GET /v1/runs/:id — it returns the same terminal result the original call would have.
GET /v1/runs/:id
Retrieve a run's result by its run id — and recover after a dropped connection. If an invoke stream is interrupted (a flaky network, a proxy timeout, a deploy), reconnect here with the same run id to get the outcome.
The :id is the run id returned as id in an invoke response (the accept event's id in streaming mode, or id in the sync body).
This endpoint behaves identically to POST /v1/invoke — same response bodies, the same status / outcome / error fields, and the same streaming frames — except it attaches to an existing run instead of starting a new one. It is read-only and safe to call repeatedly; if a reconnect itself drops, just call it again.
Request
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer sd_... |
Accept | no | text/event-stream to opt into SSE streaming (or use ?stream=1) |
| Query param | Notes |
|---|---|
stream=1 | Opt into SSE streaming. Same frames as the invoke stream. |
Response
Both modes block until the run reaches a terminal state, then return its result — there are no partial responses:
- Sync (default): a single JSON body identical to the invoke sync response (
completed,errored, orcancelled). - Streaming (
?stream=1): the sameaccept → ping → completed | cancelled | errorframes as the invoke stream.
If the run has already finished, it returns immediately. If it's still in progress, the connection stays open (sending ping heartbeats in streaming mode) until it completes.
A run whose worker exited without finishing — e.g. the server was restarted mid-run — is reported as errored with error.type: "orphaned_run".
GET /v1/artifacts/:id
Download an artifact produced by a run.
Request
| Header | Required |
|---|---|
Authorization | yes (Bearer sd_...) |
Response
302 Found with a Location header pointing to a pre-signed Cloudflare R2 URL valid for 1 hour. The caller follows the redirect and downloads the file directly from R2.
Use the url returned in output.artifacts[].url of an invocation response — that URL is stable for the life of the artifact. Each request issues a fresh signed redirect.
Error responses
All errors are JSON. The shape varies by error class:
json
{ "error": "..." }Some 400s include extra fields to help you fix the request:
| Status | Error shape | When |
|---|---|---|
400 | { "error": "Unknown input keys", "unknown": [...], "allowed": [...] } | The inputs map contains keys the agent doesn't declare. allowed is the full set of valid keys. |
400 | { "error": "Missing required inputs", "missing": [...] } | One or more required inputs were not provided and have no default. missing lists the names. |
400 | { "error": "\reference_files` is not a valid input key — send files as multipart parts named ..." }` | You put reference_files in the JSON inputs map. Use multipart instead. |
400 | { "error": "This agent does not accept reference files" } | You sent file parts but the agent doesn't have the Reference Files block enabled. |
400 | { "error": "Invalid JSON payload" } / { "error": "Invalid multipart body" } | Malformed body for the declared Content-Type. |
400 | { "error": "...", "code": "unsupported_type" | "too_large" | "empty" | "filename_required" } | A file part failed validation (unsupported MIME, exceeds the per-kind size limit, empty, or missing filename). |
401 | { "error": "Missing API key" } / { "error": "Invalid API key" } | Bearer header missing or doesn't match a live key. |
404 | { "error": "Not found" } | Invocation, artifact, or run not found, or doesn't belong to the key's workspace. Also returned for a malformed (non-UUID) run or artifact id. |
405 | { "error": "Method not allowed" } | Wrong HTTP method (only POST for invoke; GET for artifacts and runs). |
500 | { "error": "..." } | Server error during run. |
Examples
Sync invoke + download artifact
bash
# Invoke the agent
RESPONSE=$(curl -sS -X POST https://api.sunnyday.run/v1/invoke/dpan4hex24akbr3pe5mw50j7 \
-H "Authorization: Bearer $SUNNYDAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs":{"customer_id":"cus_123"}}')
echo "$RESPONSE"
# Pull the first artifact URL out and download it
URL=$(echo "$RESPONSE" | jq -r '.output.artifacts[0].url')
curl -L "$URL" \
-H "Authorization: Bearer $SUNNYDAY_API_KEY" \
-o report.pdfInvoke with reference files
bash
curl -X POST https://api.sunnyday.run/v1/invoke/dpan4hex24akbr3pe5mw50j7 \
-H "Authorization: Bearer $SUNNYDAY_API_KEY" \
-F 'inputs={"customer_id":"cus_123"};type=application/json' \
-F "reference_files=@./contract.pdf" \
-F "reference_files=@./addendum.pdf"The ;type=application/json hint sets the part's content type — the server tolerates plain text too, but adding the hint helps tools that auto-detect.
Streaming invoke
bash
curl -N -X POST 'https://api.sunnyday.run/v1/invoke/dpan4hex24akbr3pe5mw50j7?stream=1' \
-H "Authorization: Bearer $SUNNYDAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs":{"customer_id":"cus_123"}}'Sample event output:
event: accept
data: {"id":"f7c2...","timestamp":"2026-04-25T18:31:57.984Z"}
event: ping
data: {"timestamp":"2026-04-25T18:32:12.412Z"}
event: ping
data: {"timestamp":"2026-04-25T18:32:27.413Z"}
event: completed
data: {"id":"f7c2...","status":"completed","outcome":"success","durationMs":34521,"output":{...}}Reconnect to a run after a dropped stream
If an invoke stream drops, reconnect with the run id from the accept event (or the id in a sync response):
bash
curl -N "https://api.sunnyday.run/v1/runs/$RUN_ID?stream=1" \
-H "Authorization: Bearer $SUNNYDAY_API_KEY"The stream resumes — accept, then ping heartbeats until the run finishes, then the terminal completed / errored / cancelled frame, the same outcome the original call would have delivered. Omit ?stream=1 to block for the result as a single JSON body instead.
Node (fetch + EventSource-style parsing)
ts
const res = await fetch(`https://api.sunnyday.run/v1/invoke/${invocationId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUNNYDAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ inputs: { customer_id: "cus_123" } }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const result = await res.json();
console.log(result.output.text);
for (const artifact of result.output.artifacts) {
console.log(artifact.filename, artifact.url);
}Node (multipart with reference files)
Requires Node 18+ (FormData and Blob are built in).
ts
import { readFile } from "node:fs/promises";
const form = new FormData();
// Inputs as a JSON-typed text part. Wrapping the string in a Blob lets you
// set the part's Content-Type to application/json.
form.append(
"inputs",
new Blob([JSON.stringify({ customer_id: "cus_123" })], {
type: "application/json",
}),
);
// One `reference_files` part per file. Pass the filename as the third arg
// so the server sees the original name.
const pdf = await readFile("./contract.pdf");
form.append(
"reference_files",
new Blob([pdf], { type: "application/pdf" }),
"contract.pdf",
);
const res = await fetch(`https://api.sunnyday.run/v1/invoke/${invocationId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUNNYDAY_API_KEY}`,
// Do NOT set Content-Type yourself — fetch generates the multipart
// boundary and adds the correct header automatically. Setting it
// manually breaks the request.
},
body: form,
});
if (!res.ok) throw new Error(`API error: ${res.status} ${await res.text()}`);
const result = await res.json();A few common pitfalls:
- Don't set
Content-Type. Letfetchset it; otherwise the multipart boundary in the body won't match the header. - Use
Blob, notFile, unless you're in a browser context.Fileexists in Node 20+ butBlobworks everywhere ≥18. - Filename matters. Pass it as the third argument of
form.append("reference_files", blob, filename)— without it, the server sees an empty filename and rejects the upload. - Repeat the part name for multiple files. Don't try
reference_files[0]/reference_files[1]; the server readsform.getAll("reference_files").
Notes & limits
- Invocation IDs are not idempotent. Removing and re-adding an invocation generates a new ID.
- Artifact URLs are stable but require the API key on each request. Keep the URL, not the redirect target.
- Run history. Every API invocation produces a row in the run history visible in the Sunny Day app, tagged with the API key that initiated it.
- Recovering dropped runs. Runs complete via sync response or SSE; if the connection drops, fetch the outcome by run id with
GET /v1/runs/:id. Webhooks may follow. - Schedule triggers are a separate concept (run on a cron schedule) and are not part of the API surface.