Skip to content

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

HeaderRequiredNotes
AuthorizationyesBearer sd_...
Content-Typeyesapplication/json for text-only inputs, or multipart/form-data when sending files
Acceptnotext/event-stream to opt into SSE streaming (alternative to ?stream=1)
Query paramNotes
stream=1Opt 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 nameTypeNotes
inputstextJSON-encoded object with the same shape as the JSON body above. Optional.
reference_filesfileRepeat 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:

ValueMeaning
completedThe run finished without throwing.
erroredThe run could not finish cleanly. See error.type for the cause.
cancelledThe run was interrupted before reaching a natural end (user-cancel or HTTP client disconnect).

When status is "errored", error.type indicates the cause:

error.typeMeaning
timeoutThe run exceeded the maximum runtime budget and was aborted by the server.
execution_errorThe agent loop threw — typically a tool failure or model error.
internal_errorA failure in the API layer before or around the run loop. Rare; safe to retry.
orphaned_runThe 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.

ValueMeaning
successThe agent fully completed the requested objectives.
partialThe agent completed some but not all objectives, or completed them with significant issues.
failThe agent did not meaningfully complete the objectives.
nullNo 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).

EventWhenBody
acceptImmediately when the stream opens.{ "id": "<run id>", "timestamp": "<ISO 8601>" }
pingEvery 15 seconds while the run is in flight.{ "timestamp": "<ISO 8601>" }
completedRun finished cleanly.Same shape as the sync success body (id, status: "completed", durationMs, output).
cancelledRun was interrupted (user-cancel or HTTP client disconnect).Same shape as completed, with status: "cancelled". May include partial output.
errorRun 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 EventSource dispatches a built-in error event for connection failures. To distinguish a server-emitted terminal error from a connection drop, check whether event.data is 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

HeaderRequiredNotes
AuthorizationyesBearer sd_...
Acceptnotext/event-stream to opt into SSE streaming (or use ?stream=1)
Query paramNotes
stream=1Opt 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, or cancelled).
  • Streaming (?stream=1): the same accept → ping → completed | cancelled | error frames 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

HeaderRequired
Authorizationyes (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:

StatusError shapeWhen
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.pdf

Invoke 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. Let fetch set it; otherwise the multipart boundary in the body won't match the header.
  • Use Blob, not File, unless you're in a browser context. File exists in Node 20+ but Blob works 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 reads form.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.