← Docs

Jobs API Reference

Base URL: https://v0.daslab.dev

The Jobs API is the core interface for AI interactions in Daslab. Every AI conversation is a job. A job contains calls — each call is a unit of work (LLM inference, tool execution, user prompt, etc.). Messages are derived from calls, not stored separately.

Clients

ClientDescriptionInstall
CLIRust CLI with streaming chat REPLcurl -fsSL https://releases.daslab.dev/install.sh \sh
iOSSwiftUI app with WebView chatApp Store (coming soon)

See cli/README.md for full CLI documentation.

Authentication

All endpoints require authentication via one of:

MethodHeader
JWT tokenAuthorization: Bearer eyJ...
API keyAuthorization: Bearer dk_...
API key (alt)Authorization: ApiKey dk_...

API keys are prefixed with dk_ and looked up directly in the database. JWTs are signed with the server's JWT_SECRET.


Endpoints

Create Job

POST /api/jobs

Creates a new job and immediately starts processing.

There are two modes:

Conversation mode (default): The server creates a prompt_user call (completed with the user's message) and an llm_generate call, then dispatches the execution engine. Pre-seeded DAG mode: When calls is provided, the server creates the specified tool calls directly — no prompt, no LLM. The engine runs the DAG to completion. See Actions & Pre-Seeded DAGs for details. Request (conversation):
{
  "message": "Analyze our GitHub issues",
  "scene_id": "uuid",
  "agent_id": "sonnet",
  "file_urls": ["https://..."],
  "asset_ids": ["uuid1", "uuid2"],
  "id": "optional-client-provided-id"
}
Request (pre-seeded DAG):
{
  "calls": [
    { "id": "step1", "tool": "merge_pull_request", "input": { "owner": "acme", "repo": "api", "pull_number": 42 } },
    { "id": "step2", "tool": "delete_branch", "input": { "owner": "acme", "repo": "api", "branch": "feat-x" }, "waits_for": ["step1"] }
  ],
  "title": "Merge PR #42",
  "asset_ids": ["uuid1"]
}
FieldTypeRequiredDescription
messagestringYes (unless file_urls or calls)The user's message
scene_idstringNoScene to associate the job with
agent_idstringNoAgent ID — built-in slug (e.g. "sonnet") or user-created asset ID (e.g. "ast_xxx"). Defaults to scene's last agent or server default. For pre-seeded DAGs, defaults to "direct".
file_urlsstring[]NoUploaded file URLs (images, PDFs, audio)
asset_idsstring[]NoAsset IDs to enable for tool access
idstringNoClient-provided job ID (e.g. for idempotency)
callsobject[]NoPre-seeded tool calls (see below). When provided, message is optional.
calls[].idstringNoStep ID for waits_for references
calls[].toolstringYesMCP tool name
calls[].inputobjectYesTool input parameters
calls[].waits_forstring[]NoStep IDs this depends on (forms the DAG)
titlestringNoJob title (auto-generated from message if omitted)
Response: 201 Created
{
  "job_id": "job_abc123",
  "status": "created"
}

After receiving the response, connect to the SSE stream to receive real-time updates.


List Jobs

GET /api/jobs

Lists jobs from the database, sorted by updated_at DESC.

Query Parameters:
ParamTypeDefaultDescription
scene_idstringFilter by scene. Without this, returns only the authenticated user's jobs. With it, returns all jobs in the scene (if user is an org member).
limitnumber50Max results
offsetnumber0Pagination offset
Response:
{
  "jobs": [
    {
      "id": "job_abc123",
      "title": "GitHub Issues Analysis",
      "scene_id": "uuid",
      "user_id": "uuid",
      "org_id": "uuid",
      "status": "waiting",
      "agent_id": "sonnet",
      "asset_ids": ["uuid1"],
      "total_cost": 0.001234,
      "total_input_tokens": 1000,
      "total_output_tokens": 500,
      "parent_job_id": null,
      "created_at": "2026-02-17T10:00:00Z",
      "updated_at": "2026-02-17T10:01:00Z",
      "completed_at": null
    }
  ]
}

Get Job

GET /api/jobs/:id

Returns a job with all its calls and derived messages. Supports cursor-based diffing.

Query Parameters:
ParamTypeDescription
afterstringCall ID cursor. When provided, only returns calls updated after this call's timestamp.
Response:
{
  "job": {
    "id": "job_abc123",
    "title": "...",
    "status": "waiting",
    "agent_id": "...",
    "asset_ids": [],
    "user_id": "...",
    "org_id": "...",
    "scene_id": "...",
    "parent_job_id": null,
    "created_at": 1738372841000,
    "updated_at": 1738372845000
  },
  "calls": [
    {
      "id": "1738372841000-a1b2c3d4e5f6",
      "tool": "prompt_user",
      "status": "completed",
      "input": {},
      "output": { "content": "Analyze our GitHub issues" },
      "created_at": 1738372841000,
      "updated_at": 1738372841000
    },
    {
      "id": "1738372841000-f6e5d4c3b2a1",
      "tool": "llm_generate",
      "status": "completed",
      "input": { "model": "claude-sonnet-4-5-20250929" },
      "output": {},
      "cost": 0.001,
      "input_tokens": 800,
      "output_tokens": 200,
      "duration_ms": 3400,
      "created_at": 1738372841000,
      "completed_at": 1738372845000
    }
  ],
  "messages": [
    { "id": "...", "role": "user", "content": "Analyze our GitHub issues", "file_urls": null, "created_at": 1738372841000 },
    { "id": "...", "role": "assistant", "content": "I'll look into your GitHub issues...", "created_at": 1738372845000 }
  ]
}
Notes:
  • calls are the source of truth. messages is a derived convenience format (user messages from prompt_user, assistant messages from notify_user).
  • llm_generate call inputs are stripped (only model is returned) to avoid sending the full message history back.
  • The after cursor filters calls only. messages always includes the full history.

Stream Job (SSE)

GET /api/jobs/:id/stream

Server-Sent Events stream for real-time job updates. This is the primary way to receive LLM output and tool status in real time.

Query Parameters:
ParamTypeDescription
afterstringCall ID cursor. Only sends calls updated after this call's timestamp. Use this when reconnecting to avoid replaying history.
Headers:
Accept: text/event-stream
Response: text/event-stream

Event Types

sync — Initial state dump (sent once on connection):
data: {"type":"sync","calls":[...]}
update — Incremental updates (sent as calls change):
data: {"type":"update","calls":[...]}
done — Job reached a terminal state:
data: {"type":"done","status":"waiting","jobId":"...","title":"...","tokenUsage":{"inputTokens":1000,"outputTokens":500,"totalTokens":1500,"estimatedCost":0.001234}}
error — Stream error:
data: {"type":"error","error":"..."}
Heartbeat (every 15s, SSE comment):
: heartbeat

Reading Streaming LLM Output

During streaming, llm_generate calls have a partial field that grows as text is generated:

{
  "id": "...",
  "tool": "llm_generate",
  "status": "running",
  "partial": "I'll analyze your GitHub issues. Let me start by..."
}

To compute deltas, track the length of partial per call ID. The new text is partial[previousLength..].

Stream Lifecycle

  1. Client connects → receives sync event with current state
  2. If job is already terminal (waiting/failed) → receives done event → stream closes
  3. If job is running → receives update events as calls change
  4. When job reaches terminal state → receives done event → stream closes
Important: The stream endpoint is read-only. It does not start job execution. Only POST /api/jobs and POST /api/jobs/:id/input dispatch the execution engine.

Send Input

POST /api/jobs/:id/input

Send a follow-up message or reply to a waiting prompt. This is used for multi-turn conversations.

Request:
{
  "content": "Show me the most recent ones",
  "file_urls": ["https://..."],
  "mode": "interrupt"
}
FieldTypeRequiredDescription
contentstringYes (unless file_urls)The user's message
file_urlsstring[]NoAttached file URLs
modeinterruptNointerrupt (default) cancels stale executor turns and queues a fresh turn immediately.
Response:
{
  "status": "input_received"
}
Behavior:
  • If the job has a prompt_user call in waiting status, completes it with the user's reply
  • If no waiting calls exist, creates a new prompt_user call marked as an interrupt
  • Creates a new llm_generate (or other executor) call
  • If the job was in waiting or failed status, resets to running (failed jobs can be retried)
  • Dispatches the execution engine

After calling this, reconnect to the SSE stream with the after cursor to receive updates without replaying history.


Stop Job

POST /api/jobs/:id/stop

Cancels all pending/running/ready calls and sets the job to waiting.

Request: Empty body or {} Response:
{
  "status": "stopped",
  "cancelled_calls": 3
}

Or if already stopped:

{
  "status": "already_stopped",
  "job_status": "waiting"
}

Submit Tool Result

POST /api/jobs/:id/calls/:callId/result

Submit the result of a client-executed tool call. Used when the server dispatches a tool that must run on the client device (e.g., iOS Contacts, Calendar, HomeKit).

Request:
{
  "output": { "contacts": [...] },
  "duration_ms": 150
}

Or on failure:

{
  "error": "User denied access to contacts"
}
FieldTypeRequiredDescription
outputobjectNoTool result (defaults to { result: "completed" })
errorstringNoIf present, marks the call as failed
duration_msnumberNoExecution duration
Response:
{
  "status": "result_received"
}
Preconditions: The call must be in waiting status and must belong to the specified job.

Continue Job

POST /api/jobs/:id/continue

Manually continue a job in a new context window. Used when the conversation gets too long. The server compresses the conversation history and creates a child job.

Precondition: Job must be in waiting status. Request: Empty body Response:
{
  "child_job_id": "job_xyz789"
}

The child job has parent_job_id set to the original job. The client should switch to streaming the child job.


Delete Job

DELETE /api/jobs/:id

Permanently deletes a job and all its calls from both Redis and Postgres.

Response:
{
  "success": true
}

Data Model

Job

FieldTypeDescription
idstringUnique ID (server-generated or client-provided)
titlestringAuto-generated title (via Claude Haiku, fire-and-forget)
scene_idstringAssociated scene
user_idstringOwner
org_idstringOrganization
statusstringrunning \waiting \failed
agent_idstringAgent ID (built-in slug or user-created asset ID)
asset_idsstring[]Enabled assets for tool access
parent_job_idstring \nullParent job (for context continuations)
total_costnumberAccumulated LLM cost (USD)
total_input_tokensnumberTotal input tokens across all LLM calls
total_output_tokensnumberTotal output tokens
created_atISO 8601Creation time
updated_atISO 8601Last update
completed_atISO 8601 \nullWhen the job last reached a terminal state

Job Status

running  →  waiting    (idle, awaiting user input)
running  →  failed     (unrecoverable error)
waiting  →  running    (user sends input via POST /input)
failed   →  running    (user retries via POST /input)

Jobs don't auto-complete. They stay in waiting after the LLM responds, allowing multi-turn conversations. A job in waiting is ready for the next user message.

Call

FieldTypeDescription
idstringFormat: {timestamp}-{sha256_12chars} — deterministic, content-addressed
toolstringTool name (see Call Types)
statusstringpending \ready \running \waiting \completed \failed \cancelled
inputobjectTool-specific input parameters
outputobject \nullTool result
partialstring \nullStreaming text (for llm_generate and some tools)
errorstring \nullError message if failed
costnumber \nullLLM cost for this call (USD)
input_tokensnumber \nullInput tokens
output_tokensnumber \nullOutput tokens
duration_msnumber \nullExecution duration
created_atnumberUnix timestamp (ms)
updated_atnumberUnix timestamp (ms)
started_atnumber \nullWhen execution began
completed_atnumber \nullWhen execution finished

Call Types

ToolDescriptionExecution
llm_generateLLM inferenceServer. Streams partial text.
prompt_userUser messageCreated on job start and follow-ups. Goes to waiting if the LLM asks a question.
notify_userAssistant response textServer. Contains input.content with the text the LLM wants the user to see.
* (any other)MCP tool callServer or client. Examples: github_search, sandbox_run, contacts_search

Call Status Flow

pending  →  ready      (all dependencies met)
ready    →  running    (claimed by executor)
running  →  completed  (success)
running  →  failed     (error)
running  →  waiting    (needs external input — prompt_user, client tools)
running  →  cancelled  (stopped by user)
waiting  →  completed  (input received)

Client-Executed Tools

Some tools have execution_target: "client" — these are dispatched to the client device rather than executed on the server. The call transitions to waiting and the client must submit the result via POST /api/jobs/:id/calls/:callId/result.

Examples: contacts_search, contacts_create, calendar_events, reminders_list, homekit_status, files_list


Implementation Guide

Minimal Client (Polling)

The simplest integration uses polling:

1. POST /api/jobs          → get job_id
2. Loop:
   GET /api/jobs/:id       → check status, read calls/messages
   Sleep 1-2s
   Until status == "waiting" or "failed"
3. POST /api/jobs/:id/input → send follow-up
4. Go to step 2

Streaming Client (Recommended)

For real-time updates, use SSE:

1. POST /api/jobs                    → get job_id
2. GET /api/jobs/:id/stream          → connect SSE
3. Process events:
   - sync:   render initial state
   - update: apply incremental changes
             track partial text deltas per call ID
   - done:   close connection, show final state
4. POST /api/jobs/:id/input          → send follow-up
5. GET /api/jobs/:id/stream?after=X  → reconnect with cursor
6. Go to step 3

Cursor Tracking

After each sync/update event, track the latest call ID you've seen. Pass it as the after parameter when reconnecting to avoid replaying history:

GET /api/jobs/:id/stream?after=1738372841000-a1b2c3d4e5f6

Deriving Messages from Calls

Calls are the source of truth. To build a chat-style message list:

  1. Sort calls by created_at
  2. prompt_user calls (completed) → user messages (output.content is the text, output.file_urls for attachments)
  3. llm_generate calls → assistant turn boundaries. Each starts a new assistant message group.
  4. Tool calls between llm_generate calls → tool invocations within the assistant turn
  5. notify_user calls → the assistant's visible text response (input.content or output.content)
  6. Streaming text comes from llm_generate.partial (during running status)

Handling Client-Executed Tools

If your client supports local tool execution (e.g., device APIs):

1. During streaming, watch for calls with execution_target == "client"
   and status == "waiting"
2. Execute the tool locally using call.tool and call.input
3. POST /api/jobs/:id/calls/:callId/result
   with { output: {...} } or { error: "..." }
4. Job automatically resumes processing

Context Continuation

When conversations get long, the server may automatically create a child job with compressed context. Watch for:

  • A notify_user call with output.job_continued containing the child job ID
  • Switch to streaming the child job

For manual continuation, call POST /api/jobs/:id/continue and switch to the returned child_job_id.


Scheduled Jobs & Webhooks

Jobs can run on a schedule (cron, interval) or be triggered by webhooks. A scheduled job is a template — it doesn't run itself, it spawns child jobs.

Creating a Scheduled Job

Include a schedule object in the create job request:

{
  "message": "Check for new PRs and summarize them",
  "scene_id": "uuid",
  "schedule": {
    "trigger": "cron",
    "cron": "0 9 * * 1-5",
    "timezone": "America/New_York",
    "enabled": true
  }
}

For pre-seeded DAG schedules (no LLM), add calls to the schedule:

{
  "schedule": {
    "trigger": "interval",
    "interval_seconds": 3600,
    "enabled": true,
    "calls": [
      { "id": "deploy", "tool": "create_deploy", "input": { "serviceId": "srv-xxx" } }
    ]
  },
  "title": "Hourly deploy"
}
Schedule FieldTypeDescription
triggerstring"cron", "interval", or "webhook"
cronstring?5-field cron expression (e.g. "0 9 1-5")
timezonestring?IANA timezone (e.g. "America/New_York")
interval_secondsnumber?Seconds between runs (for interval trigger)
enabledbooleanWhether the schedule is active
max_concurrentnumber?Max child jobs running simultaneously (default 1)
callsobject[]?Pre-seeded DAG calls (skips LLM, uses direct executor)
calls[].idstring?Step ID for waits_for references
calls[].toolstringMCP tool name
calls[].inputobjectTool input parameters
calls[].waits_forstring[]?Step IDs this call depends on

Webhook Triggers

For webhook-triggered templates, set trigger: "webhook". The server auto-generates a webhook_secret.

POST /api/jobs/:id/trigger
x-webhook-secret: <secret>
Content-Type: application/json

{ "event": "push", "ref": "refs/heads/main" }

The webhook payload is appended to the template's prompt as a JSON code block. For DAG templates (schedule.calls), the webhook triggers the fixed DAG directly.

Update Schedule

PATCH /api/jobs/:id/schedule

Update the schedule config and/or the template's prompt.

{
  "cron": "0 10 * * *",
  "timezone": "Europe/Berlin",
  "message": "Updated prompt text"
}

Error Handling

All error responses follow this format:

{
  "error": "Human-readable error message"
}
StatusMeaning
400Bad request (missing fields, invalid state)
401Authentication required or token expired
403Not authorized to access this resource
404Job or call not found
500Server error

Retrying Failed Jobs

A job in failed status can be retried by sending a new message via POST /api/jobs/:id/input. The job resets to running and the engine re-dispatches.


Rate Limits & Constraints

  • Max concurrent calls per job: 10 (server-enforced backpressure)
  • Streaming throttle: Partial text updates published at most every 100ms
  • SSE heartbeat: Every 15 seconds
  • Redis TTL: Completed/failed jobs expire from Redis after 24 hours (persisted in Postgres)
  • No HTTP rate limiting on job endpoints (planned for launch)