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
| Client | Description | Install | |
|---|---|---|---|
| CLI | Rust CLI with streaming chat REPL | curl -fsSL https://releases.daslab.dev/install.sh \ | sh |
| iOS | SwiftUI app with WebView chat | App Store (coming soon) |
See cli/README.md for full CLI documentation.
Authentication
All endpoints require authentication via one of:
| Method | Header |
|---|---|
| JWT token | Authorization: Bearer eyJ... |
| API key | Authorization: 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 aprompt_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"]
}
| Field | Type | Required | Description |
|---|---|---|---|
message | string | Yes (unless file_urls or calls) | The user's message |
scene_id | string | No | Scene to associate the job with |
agent_id | string | No | Agent 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_urls | string[] | No | Uploaded file URLs (images, PDFs, audio) |
asset_ids | string[] | No | Asset IDs to enable for tool access |
id | string | No | Client-provided job ID (e.g. for idempotency) |
calls | object[] | No | Pre-seeded tool calls (see below). When provided, message is optional. |
calls[].id | string | No | Step ID for waits_for references |
calls[].tool | string | Yes | MCP tool name |
calls[].input | object | Yes | Tool input parameters |
calls[].waits_for | string[] | No | Step IDs this depends on (forms the DAG) |
title | string | No | Job title (auto-generated from message if omitted) |
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.
| Param | Type | Default | Description |
|---|---|---|---|
scene_id | string | — | Filter 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). |
limit | number | 50 | Max results |
offset | number | 0 | Pagination offset |
{
"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:| Param | Type | Description |
|---|---|---|
after | string | Call ID cursor. When provided, only returns calls updated after this call's timestamp. |
{
"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:
callsare the source of truth.messagesis a derived convenience format (user messages fromprompt_user, assistant messages fromnotify_user).llm_generatecall inputs are stripped (onlymodelis returned) to avoid sending the full message history back.- The
aftercursor filterscallsonly.messagesalways 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:| Param | Type | Description |
|---|---|---|
after | string | Call ID cursor. Only sends calls updated after this call's timestamp. Use this when reconnecting to avoid replaying history. |
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
- Client connects → receives
syncevent with current state - If job is already terminal (
waiting/failed) → receivesdoneevent → stream closes - If job is running → receives
updateevents as calls change - When job reaches terminal state → receives
doneevent → stream closes
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"
}
| Field | Type | Required | Description |
|---|---|---|---|
content | string | Yes (unless file_urls) | The user's message |
file_urls | string[] | No | Attached file URLs |
mode | interrupt | No | interrupt (default) cancels stale executor turns and queues a fresh turn immediately. |
{
"status": "input_received"
}
Behavior:
- If the job has a
prompt_usercall inwaitingstatus, completes it with the user's reply - If no waiting calls exist, creates a new
prompt_usercall marked as an interrupt - Creates a new
llm_generate(or other executor) call - If the job was in
waitingorfailedstatus, resets torunning(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.
{}
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"
}
| Field | Type | Required | Description |
|---|---|---|---|
output | object | No | Tool result (defaults to { result: "completed" }) |
error | string | No | If present, marks the call as failed |
duration_ms | number | No | Execution duration |
{
"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 inwaiting 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
| Field | Type | Description | ||
|---|---|---|---|---|
id | string | Unique ID (server-generated or client-provided) | ||
title | string | Auto-generated title (via Claude Haiku, fire-and-forget) | ||
scene_id | string | Associated scene | ||
user_id | string | Owner | ||
org_id | string | Organization | ||
status | string | running \ | waiting \ | failed |
agent_id | string | Agent ID (built-in slug or user-created asset ID) | ||
asset_ids | string[] | Enabled assets for tool access | ||
parent_job_id | string \ | null | Parent job (for context continuations) | |
total_cost | number | Accumulated LLM cost (USD) | ||
total_input_tokens | number | Total input tokens across all LLM calls | ||
total_output_tokens | number | Total output tokens | ||
created_at | ISO 8601 | Creation time | ||
updated_at | ISO 8601 | Last update | ||
completed_at | ISO 8601 \ | null | When 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
| Field | Type | Description | ||||||
|---|---|---|---|---|---|---|---|---|
id | string | Format: {timestamp}-{sha256_12chars} — deterministic, content-addressed | ||||||
tool | string | Tool name (see Call Types) | ||||||
status | string | pending \ | ready \ | running \ | waiting \ | completed \ | failed \ | cancelled |
input | object | Tool-specific input parameters | ||||||
output | object \ | null | Tool result | |||||
partial | string \ | null | Streaming text (for llm_generate and some tools) | |||||
error | string \ | null | Error message if failed | |||||
cost | number \ | null | LLM cost for this call (USD) | |||||
input_tokens | number \ | null | Input tokens | |||||
output_tokens | number \ | null | Output tokens | |||||
duration_ms | number \ | null | Execution duration | |||||
created_at | number | Unix timestamp (ms) | ||||||
updated_at | number | Unix timestamp (ms) | ||||||
started_at | number \ | null | When execution began | |||||
completed_at | number \ | null | When execution finished |
Call Types
| Tool | Description | Execution |
|---|---|---|
llm_generate | LLM inference | Server. Streams partial text. |
prompt_user | User message | Created on job start and follow-ups. Goes to waiting if the LLM asks a question. |
notify_user | Assistant response text | Server. Contains input.content with the text the LLM wants the user to see. |
* (any other) | MCP tool call | Server 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:
- Sort calls by
created_at prompt_usercalls (completed) → user messages (output.contentis the text,output.file_urlsfor attachments)llm_generatecalls → assistant turn boundaries. Each starts a new assistant message group.- Tool calls between
llm_generatecalls → tool invocations within the assistant turn notify_usercalls → the assistant's visible text response (input.contentoroutput.content)- Streaming text comes from
llm_generate.partial(duringrunningstatus)
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_usercall withoutput.job_continuedcontaining 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 Field | Type | Description |
|---|---|---|
trigger | string | "cron", "interval", or "webhook" |
cron | string? | 5-field cron expression (e.g. "0 9 1-5") |
timezone | string? | IANA timezone (e.g. "America/New_York") |
interval_seconds | number? | Seconds between runs (for interval trigger) |
enabled | boolean | Whether the schedule is active |
max_concurrent | number? | Max child jobs running simultaneously (default 1) |
calls | object[]? | Pre-seeded DAG calls (skips LLM, uses direct executor) |
calls[].id | string? | Step ID for waits_for references |
calls[].tool | string | MCP tool name |
calls[].input | object | Tool input parameters |
calls[].waits_for | string[]? | 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"
}
| Status | Meaning |
|---|---|
| 400 | Bad request (missing fields, invalid state) |
| 401 | Authentication required or token expired |
| 403 | Not authorized to access this resource |
| 404 | Job or call not found |
| 500 | Server 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)