# Nightjar SDK

Submit connected Codex or Claude work from a Node.js backend and collect durable results.

**Node.js 22+ · TypeScript · ESM + CommonJS · No runtime dependencies**

Read the [pilot limits and reliability contract](./pilot.md) before delegating sensitive or irreplaceable work.

## Install and configure

```sh
npm install @silicon-intern/nightjar
```

In the Nightjar dashboard, connect your own Codex or Claude account and create a workspace key under **Developers → API keys**. Account sign-in and tool authorization happen in the dashboard. Your backend only needs the key:

```sh
NIGHTJAR_API_KEY=your_workspace_key
```

```ts
import Nightjar from '@silicon-intern/nightjar';

const nightjar = new Nightjar();
const result = await nightjar.run('Return a short checklist for testing a signup flow.');
console.log(result.output);
```

No saved agent or workspace ID is required. The key identifies its workspace; Nightjar selects a ready model account belonging to the key creator. For self-hosting, also set `NIGHTJAR_BASE_URL`.

Keys stay on the server. Other workspace members' subscriptions are not inherited. Dashboard keys have `runs:read` and `runs:write`; they cannot administer workspaces or connect accounts.

Named imports and CommonJS also work: `import { Nightjar } from '@silicon-intern/nightjar'` or `const { Nightjar } = require('@silicon-intern/nightjar')`.

## Direct HTTP integration without MCP

Base URL: `https://app.usenightjar.com`. Send `Authorization: Bearer <workspace-key>` and, for JSON writes, `Content-Type: application/json`. Keep the actual key in the caller's supported backend secret facility. It must remain available to scheduled workers after restart; an ephemeral agent sandbox alone is insufficient. These endpoints use ordinary JSON over HTTPS, not MCP JSON-RPC. MCP OAuth grants are currently accepted at `/mcp`; use a dedicated workspace API key for these HTTP endpoints.

| Operation | HTTP request | Response |
| --- | --- | --- |
| Submit a goal | `POST /api/tasks`, with `Idempotency-Key` | `{task}`; new work is HTTP 202, an identical replay is HTTP 200 |
| Read a run | `GET /api/tasks/{runId}` | `{task, events}` |
| Read conversation state | `GET /api/conversations/{conversationId}` | `{id, workspaceId, runs, truncated}` |
| Replay conversation events | `GET /api/conversations/{conversationId}/events?after=0&limit=50&waitSeconds=20` | `{conversationId, events, nextCursor, hasMore}` |
| Answer a saved question | `POST /api/tasks/{runId}/answer` | `{task}` containing the linked continuation |
| Cancel a run | `POST /api/tasks/{runId}/cancel` | `{task}`; continue observing if execution remains unconfirmed |

A submission body can be as small as:

```json
{
  "prompt": "Ask me which color to use, then wait for my answer.",
  "provider": "codex",
  "connectionIds": [],
  "timeoutSeconds": 120
}
```

The key selects the workspace and its creator's model account; no saved agent is required. Persist the request body and a unique job ID before submitting, send that ID as `Idempotency-Key`, and save the returned `task.id` and `task.conversationId`. After an uncertain response, retry the exact body/key. Do not generate a new job ID merely because the HTTP request timed out.

Use the conversation event endpoint across all continuations. `after` is the last durably processed sequence, `limit` is 1–100, and `waitSeconds` is 0–20. Use an HTTP timeout longer than the requested server wait. Fetch current run state for an event's `runId`; events do not contain full outputs. Persist notification work before advancing the cursor, deduplicate by event ID, and save a channel receipt after delivery. Drain additional pages when `hasMore` is true. Reconnect with the saved cursor after interruption; a phone tab or an unverified background promise is not a persistent observer.

When `task.attention.kind` is `question` and `task.lifecycle.canAnswer` is true, relay the exact `attention.message` and optional choices. Submit the owner's actual answer with the saved question ID:

```json
{
  "requestId": "the-exact-attention-id-from-the-run",
  "answer": "The owner's actual reply"
}
```

For structured forms, use `values` and `interactionVersion` as described below. Save and observe the returned continuation. Retrying an identical accepted answer returns the same continuation; a changed answer returns `409 answer_conflict`. The original run ID alone is not enough to route multiple pending questions safely.

Report a completed result only when `task.lifecycle.resultReady` is true. For published apps, require server-returned `apps[].state: "live"` and `liveUrl`; a URL in model text is not a deployment receipt. Handle failed, cancelled, and unresolved runs explicitly. On HTTP 401 or 403, report the authorization problem instead of switching to browser automation. Honor `Retry-After` on HTTP 429 and retry transient failures with backoff, preserving submission and answer identity.

Signed webhooks can accelerate observation when the caller has a supported HTTPS receiver; event replay remains the recovery path. Neither an API key nor a webhook subscription makes an external assistant receive or deliver messages automatically.

## Give work access to your tools

With only `NIGHTJAR_API_KEY` configured, the first example grants no Nightjar tool connections. To investigate real repository activity, connect GitHub in the dashboard, then select it in **Developers → Quickstart** and copy the example with its actual connection ID:

```ts
const result = await nightjar.run({
  prompt:
    'Summarize the last seven days of changes in the connected repository. Do not modify anything.',
  connectionIds: ['YOUR_GITHUB_CONNECTION_ID'],
});
console.log(result.output);
```

Replace the placeholder with the selected connection ID. Add other authorized tools through `connectionIds` as needed. Without a saved agent, omitting this list grants no Nightjar service connections; adding a connection to the workspace does not automatically grant it to your runs. The server enforces each connection's saved resource and access limits.

## Submit without blocking your application

The SDK is optional. Clients with direct HTTPS support can use the [HTTP integration contract](#direct-http-integration-without-mcp) below; MCP support is not required.

```ts
const run = await nightjar.runs.create(
  {
    input: 'Return a short checklist for testing a signup flow.',
    metadata: { jobId: 'signup-checklist-001' },
  },
  { idempotencyKey: 'signup-checklist-001' },
);
// Persist run.id beside your job ID, then return HTTP 202.
// A worker can wait later; a webhook can notify your application instead.
const result = await nightjar.runs.result(run.id);
console.log(result.output);
```

`runs.create` returns after durable acceptance, usually `queued`. It accepts an object; the string shorthand belongs to `run`. Use the **same body and idempotency key** to recover an uncertain submission. A different body with the same key returns `409 idempotency_conflict`; a new key creates new work. Keys contain 1–200 printable ASCII characters without spaces.

`run` combines submission and waiting for success. Both `run` and `runs.result` throw `NightjarRunError` for unsuccessful stopped work, with the snapshot in `error.run`. This includes `failed`, `cancelled`, `needs_input`, and a completed model whose deliverables failed. `runs.wait` returns stopped work for you to handle, while continuing to wait for a completed model's pending publication.

| Status              | Handling                                                                                                  |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `queued`, `running` | Keep waiting or return to the run later.                                                                  |
| `completed`         | Read the result when `lifecycle.resultReady` is true; publication may still be pending or require action. |
| `failed`            | Inspect `error`; partial output is not success.                                                           |
| `cancelled`         | Work was cancelled; keep observing if execution is still unconfirmed.                                     |
| `needs_input`       | Inspect `attention` and `lifecycle`; enable an answer only when `canAnswer` is true.                      |

Waits default to one-second polling and 30 minutes. `timeoutMs` limits local waiting; `requestTimeoutMs` limits each HTTP operation. Run input `timeoutSeconds` controls remote execution (30–86,400 seconds; default 1,800 unless inherited from an agent). Requests and waits accept an `AbortSignal`. A local timeout or abort does not cancel remote work; use `runs.cancel(id)` explicitly.

## Questions, answers, and recovery

Use SDK **1.3.0 or later** with the updated server for `runs.answer`, `runs.recover`, and the additional webhook event types below. SDK 1.2.0 does not contain these methods. Existing integrations can upgrade the SDK or call the HTTP endpoints directly.

Every updated server snapshot includes `lifecycle`: use `resultReady` to recognize a usable completed result, `observing` to keep observing recovery or publication, and `canAnswer` to enable an answer form. `status` remains compatible with existing clients. `needs_input` can mean a question, a connection problem, or unresolved execution; inspect `attention.kind` and `lifecycle` instead of guessing from output. A client connecting to an older server can fall back to `status` when `lifecycle` is absent.

```ts
const run = await nightjar.runs.get(savedRunId);
if (run.lifecycle?.canAnswer && run.attention?.kind === 'question') {
  // Send attention.id, message and optional choices to your application's UI.
  // Obtain the user's answer there; keep the Nightjar key in this backend.
  const continuation = await nightjar.runs.answer(run.id, {
    requestId: run.attention.id,
    answer: answerFromYourUser,
  });
  // Persist and observe continuation.id; conversationId stays stable.
}
```

Both providers receive the internal `request_input` tool, including runs with no service connections. Nightjar records the question before the agent stops; `canAnswer` becomes true after execution is confirmed stopped. An answer is atomic and idempotent by question ID: the same answer returns the same continuation, and a conflicting answer returns `409 answer_conflict`. A cancelled/resolved question rejects later answers. Answering requires write permission and access to the original Environment. HTTP: `POST /api/tasks/:id/answer` with `{requestId, answer}`.

Original goals, accepted answers, and working files persist across continuations. Immediate parent results retain up to 100,000 characters; three earlier turns retain bounded excerpts (4,000 characters of instruction and 16,000 of result each). Accepted answers are limited to 100,000 serialized characters per conversation. Run IDs identify attempts; `conversationId` identifies their shared conversation. Ordinary follow-ups and explicit retries retain lineage.

`lifecycle.state: "recovering"` means Nightjar is observing the existing process. `recovery` reports attempt count, next attempt time, and the latest diagnostic. After five automatic attempts, offer `runs.recover(id)` / `POST /api/tasks/:id/recover` to request another bounded recovery cycle. Recovery never repeats the goal or extends a lease. Keep the reservation until `executionUnconfirmed` clears. A confirmed, stopped run can then be continued with an explicit follow-up.

`runs.wait` returns stopped failures or questions for handling; a completed model with pending publication continues waiting. `runs.result` rejects a completed run whose deliverables failed. The activity iterator remains open while `lifecycle.observing` is true, including post-execution publication/recovery updates.

Opt in to server-delivered Slack notifications with `notification: {connectionId: selectedSlackId}` when creating the run. The selected connection must be a ready Slack account with write permission and a saved self destination. This does not grant Slack tools to the agent. Scheduled runs retain the same opt-in; set `notification: null` when editing a schedule to turn it off. Answer continuations remain part of the scheduled occurrence and prevent overlapping work. Notifications survive model failure; `notification.status` reports pending, sent, or failed delivery. An ambiguous send is not blindly repeated: the dashboard asks the user to check Slack before retrying.

Subscribe to `task.attention_requested`, `task.answer_received`, `task.recovering`, `task.recovered`, and `task.updated` to observe questions, answers, recovery and publication/notification changes. All contain immutable full task snapshots with the same signed envelope and sequence rules. `task.attention_requested` can arrive before the pause is complete; the later `task.needs_input` contains `canAnswer: true` when it is safe. Existing subscriptions retain their four default stopped events until explicitly changed.

The runnable repository example, `scripts/examples/conversation-backend.ts`, demonstrates a signed receiver with durable duplicate/out-of-order handling and an answer endpoint. Run it with Node.js 24 and `pnpm exec tsx scripts/examples/conversation-backend.ts`, providing `NIGHTJAR_API_KEY`, `NIGHTJAR_WEBHOOK_SECRET`, and a separate `EXAMPLE_APP_TOKEN`. Its authenticated demo endpoints listen on loopback port 8787; send `Authorization: Bearer EXAMPLE_APP_TOKEN` to create, read, or answer a job. The receiver wakes cursor reconciliation; the durable notification outbox requires delivery receipts. See the phone integration section below. Expose the webhook route through your own public HTTPS endpoint for real delivery, and replace the demo authentication with your application's session and ownership rules before exposing job endpoints to end users.

## Structured results

Supply a provider-compatible JSON schema and a runtime parser for output your code depends on:

```ts
const result = await nightjar.run(
  {
    prompt: 'Summarize the most important checks for testing a signup flow.',
    outputSchema: {
      type: 'object',
      properties: { summary: { type: 'string' } },
      required: ['summary'],
      additionalProperties: false,
    },
  },
  {
    parse(value: unknown): { summary: string } {
      if (
        typeof value !== 'object' ||
        value === null ||
        !('summary' in value) ||
        typeof value.summary !== 'string'
      )
        throw new Error('Expected a string summary.');
      return { summary: value.summary };
    },
  },
);
console.log(result.structuredOutput.summary);
```

An existing validator works through `parse: (value) => schema.parse(value)`; async parsers also work. Parsing applies to `structuredOutput` and is available on `runs.result`. Without a parser, structured output remains `unknown`. Parser failure throws `NightjarOutputError`, with `run` and `cause`, without rerunning the model.

Nightjar sends the schema unchanged to the provider and validates the result. Schemas must be under 16 KB without references, patterns, or formats. For portability, use a Draft-07 object schema with required properties and `additionalProperties: false` on every object. Provider compatibility can still differ; unsupported schemas fail instead of being silently rewritten.

## Live applications

In an enabled pilot workspace, describe the application and ask for a public URL. Static and React/Vite publishing tools are supplied automatically to the native agent; no Vercel connection is needed. The agent builds in its Environment, reads structured errors, and can repair and retry within the same run. Updates reuse the app's stable Vercel URL.

```ts
const result = await nightjar.run({
  prompt: 'Build a React habit tracker and give me a public URL.',
  connectionIds: [],
});
for (const app of result.apps ?? []) {
  if (app.state === 'live' && app.liveUrl) console.log(app.liveUrl);
  else console.log(app.state, app.error);
}
```

`apps` is server-generated metadata, separate from `output` and any requested `structuredOutput` schema. SDK 1.2.0 includes these types; install `@silicon-intern/nightjar@^1.2.0` from npm. A completed run alone does not prove successful deployment. If an app is pending, reread the run with `runs.get(id)` until its deployment becomes terminal. App changes after the model stops emit `task.updated` with the full task snapshot; subscribe to it to observe publication through to readiness.

Published files are public. Servers, SSR, databases, and app secrets are unsupported. The current pilot allows ten apps per workspace, subject to the ten-app global pilot allowance, and 25 MiB per deployment, with a finite hosting lease. See the [pilot limits and current lease](./pilot.md#hosting-lease) before depending on a public URL. Publication metadata records the last verified deployment; a later operator pause or lease expiry can make that URL unavailable.

## Project downloads and GitHub work

The run's **Outputs** panel offers **Download ZIP** for captured project files, plus **View branch** and **View PR** when work was delivered to GitHub. **Download summary** saves the Markdown response; structured results keep their **Download JSON** action.

Run snapshots add optional `artifacts`, `github`, and `artifactError` fields. The existing `output` summary and `structuredOutput` values keep the same shape. Older runs and runs without files may have no artifacts. GitHub links identify verified remote branches and pull requests; they are not inferred from links in the model's summary. To request a push or pull request, select a connection with write access and include that instruction in the goal.

After a cloud run stops and its process cleanup is confirmed, Nightjar captures the projects it changed, including each project's unchanged files. Failed or cancelled runs can therefore have partial project files. Unrelated projects, Git history, dependencies, caches, common credential files, non-template `.env` files, and symlinks are excluded. Capture is limited to 10,000 eligible entries, 100 MiB before compression, and a 25 MiB ZIP. The pilot has a 250 MiB total artifact storage limit. No archive is created when there are no eligible project changes; a capture or storage failure appears as `artifactError` without replacing the run result.

`runs.downloadArtifact` was added in SDK **1.1.0** and is included in the current npm release:

```sh
npm install '@silicon-intern/nightjar@^1.2.0'
```

```ts
import { writeFile } from 'node:fs/promises';

const result = await nightjar.runs.result(run.id);
const project = result.artifacts?.[0];
if (project) {
  const bytes = await nightjar.runs.downloadArtifact(result.id, project.id, {
    requestTimeoutMs: 30_000,
    maxRetries: 1,
  });
  await writeFile('./project.zip', bytes);
}
for (const delivery of result.github ?? []) {
  console.log(delivery.repository, delivery.branchUrl);
  if (delivery.pullRequest) console.log(delivery.pullRequest.url);
}
if (result.artifactError) console.error(result.artifactError);
```

Each artifact includes `id`, `name`, `mediaType: 'application/zip'`, `sizeBytes`, `sha256`, and an authenticated `downloadUrl`. The SDK builds the request from the run and artifact IDs against its configured Nightjar server; it does not follow the metadata URL or redirects. It returns a `Uint8Array`, limits the download to 25 MiB, and supports the same authentication, request deadline, `AbortSignal`, and opt-in GET retries as other reads. Stopping a download does not cancel the run.

For HTTP integrations, use `GET /api/tasks/{runId}/artifacts/{artifactId}` with your workspace key. The route requires the same run visibility and `runs:read` scope as reading the task. A successful response is `application/zip`; errors use the usual JSON error envelope. The `downloadUrl` does not grant public access. Saved ZIPs are served by Nightjar and remain downloadable while the Environment sleeps. Check `artifactError` when capture is unavailable.

GitHub delivery metadata includes `repository`, `branch`, `branchUrl`, `commitSha`, and an optional `pullRequest` with `number`, `title`, and `url`. Signed task webhooks carry the same optional metadata, so a backend can download files after processing a completion event. Existing SDK 1.0 methods and result parsing are unchanged.

## Receive signed webhooks

Use signed webhooks to update your application as runs start, make progress, ask a question, recover, and deliver results. A successful submission returns the run ID; your backend receives subsequent updates without polling. Relay verified updates from your backend to your own app clients using your application's realtime transport. Nightjar does not expose a resumable SSE stream.

Register a public HTTPS receiver in **Developers → Webhooks**, choose its events, and save its one-time signing secret on your backend. Events cover the workspace; use `runId` and `data.task.metadata` to correlate your jobs. Existing endpoints and new endpoints that omit event selection receive only the four stopped event types. Select the additional events in the contract below to receive questions, answers, recovery, and result-readiness changes. Progress is optional when your application only needs state and results.

Upgrade the receiver before expanding subscriptions. SDK **1.2.0** supports queued, started, progress, and stopped events; SDK 1.1.0 and earlier typed parsers accept stopped events only:

```sh
npm install '@silicon-intern/nightjar@^1.2.0'
```

The five conversation/update event types below require SDK **1.3.0 or later**. Verify the installed package supports them before selecting them; the 1.2.0 parser rejects those event types.

Verify the exact raw bytes before JSON parsing. This example uses an application-provided durable inbox:

```ts
import express from 'express';
import { parseWebhook, NightjarWebhookError } from '@silicon-intern/nightjar/webhooks';

const app = express();
app.post('/webhooks/nightjar', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = parseWebhook({
      payload: req.body,
      signature: req.get('nightjar-signature') ?? '',
      secret: process.env.NIGHTJAR_WEBHOOK_SECRET!,
    });
    // Implement this in your application: atomically save the payload with a
    // UNIQUE constraint on event.id. Already-saved IDs are a successful no-op.
    await saveToYourDurableInbox(event.id, event);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof NightjarWebhookError) {
      res.sendStatus(error.code === 'invalid_signature' ? 401 : 400);
    } else {
      res.sendStatus(500); // A failed durable save must trigger redelivery.
    }
  }
});
app.use(express.json());
```

`saveToYourDurableInbox` is your persistence function, not an SDK method. Acknowledge with HTTP `2xx` only after durable acceptance, then process the saved event asynchronously. An event can arrive before your submission handler finishes saving the returned run ID; sending your job ID in run `metadata` helps correlate that race. Keep signing secrets, workspace keys, and payload verification on the server.

For receivers without the SDK, the `nightjar-signature` header has the form `t=UNIX_SECONDS,v1=LOWERCASE_HEX_DIGEST`. Compute HMAC-SHA256 with the signing secret over the UTF-8 timestamp, a literal period, then the exact raw request body bytes. Compare the digest in constant time and reject malformed headers and timestamps outside your allowed clock skew. The SDK's default tolerance is five minutes; keep clocks synchronized. Verify before parsing, and never reconstruct the bytes from parsed JSON. `verifyWebhook` performs this signature check and returns a boolean without validating the event shape; `parseWebhook` verifies and validates the event envelope.

## Webhook event contract

New task events have `{ id, type, createdAt, runId, workspaceId, sequence, data }`. IDs are stable strings beginning with `evt_`; `runId` equals `data.task.id`, and `workspaceId` equals `data.task.workspaceId`. `sequence` is a positive, durable activity ID assigned in increasing order by the single-host database. Compare it **within each run** to order events; gaps are normal because other activity and filtered events use the same ID space.

| Event                      | When it is emitted                                                                                 | Payload                                                                              |
| -------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `task.queued`              | The run is durably accepted. An idempotent submission replay does not create another queued event. | `data.task`: full queued run snapshot.                                               |
| `task.started`             | The worker claims the run and begins dispatching it to the runner.                                 | `data.task`: full running run snapshot.                                              |
| `task.progress`            | A meaningful activity update is persisted while the run is queued or running.                      | Compact `data.task` identity/status/metadata and `data.progress`.                    |
| `task.completed`           | The provider completes successfully; publication may still be pending.                             | Full `data.task`; check `lifecycle.resultReady` before treating the result as ready. |
| `task.failed`              | The run stops with an error.                                                                       | `data.task`: full failed run snapshot.                                               |
| `task.cancelled`           | Cancellation is recorded.                                                                          | Full `data.task`; `executionUnconfirmed` can still require recovery.                 |
| `task.needs_input`         | The provider stops for a question or a problem.                                                    | Full `data.task`; inspect `attention` and `lifecycle.canAnswer`.                     |
| `task.attention_requested` | A question is saved, before execution necessarily finishes pausing.                                | Full `data.task` with the question ID, message, and optional choices.                |
| `task.answer_received`     | An accepted answer is linked to its continuation.                                                  | Full parent `data.task`; observe `attention.continuationId` next.                    |
| `task.recovering`          | Recovery is requested or its attempt/diagnostic changes.                                           | Full `data.task` with `recovery` and `executionUnconfirmed`.                         |
| `task.recovered`           | The prior process is confirmed stopped and its Environment reservation is released.                | Full `data.task`; a pending question may now be answerable.                          |
| `task.updated`             | Publication, notification delivery, or the queue explanation changes.                              | Full `data.task`; re-evaluate `lifecycle`, including result readiness.               |

Progress is a human-readable update, not a token stream or a percentage-complete estimate. It includes agent messages, tool activity, execution notices, and relevant errors; it excludes reasoning, raw token deltas, and result payloads. `data.progress.type` preserves the source activity type: `assistant`, `tool`, `system`, `error`, `agent.message`, `tool.called`, `tool.failed`, `git.transfer`, `runner.accepted`, `runner.unreachable`, or `task.cancel_requested`. `data.progress.message` is bounded to 16,000 characters. An idle or silent provider may produce no progress messages. The compact task contains only `id`, `workspaceId`, `status` (`queued` or `running`), and `metadata`; completion events retain the full result.

Illustrative progress payload, not evidence of an executed run:

```json
{
  "id": "evt_example",
  "type": "task.progress",
  "createdAt": "2026-09-08T12:00:03.000Z",
  "runId": "task_example",
  "workspaceId": "ws_example",
  "sequence": 103,
  "data": {
    "task": {
      "id": "task_example",
      "workspaceId": "ws_example",
      "status": "running",
      "metadata": { "jobId": "signup-checklist-001" }
    },
    "progress": {
      "type": "agent.message",
      "message": "I am checking the signup flow and its validation rules."
    }
  }
}
```

The dashboard's **Send test event** emits `webhook.test`, which has `{ id, type, createdAt, data: { workspaceId, message } }` and no task or sequence. It bypasses event filtering, requires an enabled endpoint, and does not run an agent. `parseWebhook` returns a discriminated union covering test, progress, lifecycle, and terminal events. Previously queued terminal payloads may lack the new envelope fields; SDK 1.2.0 continues accepting them. For those payloads, use `data.task.id` and `data.task.workspaceId`; do not invent a missing sequence.

## Webhook delivery and ordering

Events and their subscribed deliveries are saved durably with their originating activity. Delivery runs independently of agent execution, so a long run does not hold its own queued or progress notifications until completion. The pilot dispatches due deliveries in batches of up to eight on an independent timer; this is not a latency SLA.

Delivery is **at least once**, with up to eight automatic attempts in total. Failed requests retry with exponential delays starting at five seconds (5, 10, 20, 40, 80, 160, and 320 seconds between attempts), subject to dispatch timing. Retries preserve the event ID and exact JSON body; each attempt receives a fresh timestamp and signature. Headers also include `nightjar-event-id`, `nightjar-delivery-id`, and `nightjar-delivery-attempt`. The attempt header is diagnostic; authenticate and deduplicate using the signed body. Deduplicate using the verified body's `event.id`, not its signature or delivery ID.

Arrival order is not guaranteed: independent requests, receiver failures, and retries can cause a newer event to arrive first. In your inbox worker, store the highest applied `sequence` per run and atomically apply newer snapshots only. Keep older events in an ordered activity history if useful, but do not let an older queued/progress event overwrite a completed status. A compact progress payload should update the latest message and status; it must not erase full run fields such as output or artifacts. Track each run separately, and do not wait for contiguous sequence numbers. Handle terminal payloads without sequence separately during an upgrade, without allowing them to regress a newer terminal state.

Inspect attempts in **Developers → Webhooks → Recent deliveries**. After automatic attempts are exhausted, **Retry delivery** sends the same event again; it does not rerun the agent. A process interruption can consume a reserved attempt. Manual retry is rejected while an attempt is in flight; wait for it to finish before retrying. An unavailable receiver or exhausted retries can prevent live updates from arriving. Preserve run IDs so you can reconcile with `runs.get` when needed. This recovery read is optional error handling; receiving queued, started, progress, and terminal updates does not require continuous polling.

## Webhook subscriptions

Use **Developers → Webhooks → Add endpoint** to choose events during creation, or **Edit events** on an existing endpoint. A workspace administrator manages these subscriptions in a signed-in browser session. Scoped workspace run keys cannot create, update, test, or retry webhooks.

The underlying administration API accepts the same nonempty list of supported task event names. The following JSON is an illustrative request body for `POST /api/webhooks`, authenticated with an administrator browser session:

```json
{
  "workspaceId": "YOUR_WORKSPACE_ID",
  "url": "https://your-app.example/webhooks/nightjar",
  "eventTypes": [
    "task.queued",
    "task.started",
    "task.progress",
    "task.attention_requested",
    "task.answer_received",
    "task.recovering",
    "task.recovered",
    "task.updated",
    "task.completed",
    "task.failed",
    "task.cancelled",
    "task.needs_input"
  ]
}
```

Creation returns `{ webhook, secret }`; save the one-time `secret` on your receiving server. Omitting `eventTypes` preserves the default selection: `task.completed`, `task.failed`, `task.cancelled`, and `task.needs_input`. Existing endpoints without this field use those same defaults.

To replace the selection, send `PATCH /api/webhooks/{webhookId}` in an administrator browser session:

```json
{
  "eventTypes": ["task.started", "task.progress", "task.completed", "task.failed"]
}
```

Omitting `eventTypes` in a patch leaves the selection unchanged; an empty list or unknown event name is rejected. Set `enabled: false` or use **Pause** to stop an endpoint. Subscriptions affect **future events only**: opting in does not backfill earlier activity, and changing the selection does not remove already queued deliveries. Test events bypass this selection.

## Progress and control

```ts
for await (const event of nightjar.runs.events(run.id, { after: 0 })) {
  console.log(event.id, event.type, event.message);
  // Save event.id after processing; pass it as "after" to resume.
}
```

This SDK iterator polls persisted activity and stops when the run no longer needs observation; it is not SSE. The numeric cursor is filtered locally and does not reduce server response size. These numeric activity IDs supply `sequence` on new webhooks, but differ from the stable string webhook `id`. Use signed lifecycle webhooks above when your app needs updates without polling.

- `runs.followUp(id, { prompt }, { idempotencyKey })` creates a run with prior context when `lifecycle.canFollowUp` is true.
- `runs.answer(id, { requestId, answer }, options?)` answers the exact saved question when `lifecycle.canAnswer` is true and returns its continuation.
- `runs.recover(id)` requests another bounded recovery cycle after automatic attempts are exhausted; it does not repeat model work.
- `runs.retry(id, { idempotencyKey })` creates new model work. Review any previous external changes first.
- `runs.cancel(id)` requests cancellation. Poll until final; stopping external processes can take time.

HTTP retries are off by default. Set `maxRetries` to retry GET requests or create/follow-up/answer/retry requests with an explicit idempotency key. Network errors and HTTP `408`, `429`, `500`, `502`, `503`, and `504` are retryable, within the request deadline. Cancellation, recovery, and unkeyed writes are never automatically retried. Repeating an answer manually with the same question ID and answer is safe even without an idempotency key. An HTTP retry recovers a request; `runs.retry` starts another model run.

## Optional defaults and reuse

Set `provider`, `model`, or `reasoning` when your application needs a specific model configuration. Usually omit `runnerId` so Nightjar selects your account's ready Environment.

```ts
const result = await nightjar.run({
  prompt: 'Return a short checklist for testing a signup flow.',
  provider: 'claude',
  model: 'default',
  timeoutSeconds: 300,
});
```

Choose concrete model IDs and reasoning levels from the dashboard. `default` lets the native provider choose. Discovery reads installed CLI metadata without submitting a prompt; reported models still depend on the account's entitlement and quota. Some model choices may consume provider credits. There is no SDK `models.list` method.

Saved agents are optional reusable instructions and defaults. Existing `agentId` integrations continue to work. Discover them with `nightjar.agents.list()`, then use one for repeated work:

```ts
const reviewer = nightjar.with({ agentId: 'YOUR_SAVED_AGENT_ID' });
const result = await reviewer.run('Review the most recent changes.');
```

Replace the placeholder with an agent ID. The run inherits its instructions, provider, model, reasoning, connections, output schema, and deadline, and records the agent revision. Per-run settings override these defaults. `connectionIds` replaces the inherited list; `[]` grants no Nightjar service connections. `outputSchema: null` selects plain text; omission inherits the agent schema. Use `nightjar.with({ agentId: null })` to clear a saved-agent default for subsequent submissions.

| Client option            | Default                                                                           |
| ------------------------ | --------------------------------------------------------------------------------- |
| `apiKey`                 | `NIGHTJAR_API_KEY`; required.                                                     |
| `workspaceId`, `agentId` | Optional corresponding `NIGHTJAR_WORKSPACE_ID` and `NIGHTJAR_AGENT_ID` variables. |
| `baseUrl`                | `NIGHTJAR_BASE_URL`, then `NIGHTJAR_URL`, then the hosted origin.                 |
| `requestTimeoutMs`       | `30_000`, including HTTP retries and backoff.                                     |
| `maxRetries`             | `0`; at most `5` additional attempts.                                             |
| `fetch`                  | `globalThis.fetch`; injected transports must honor `AbortSignal`.                 |

Explicit options override environment variables. `nightjar.with({ workspaceId, agentId })` creates an independent client; omitted values retain defaults, and `null` clears them. Per-run values override client defaults. An explicit `workspaceId` must still match the key's workspace access. Neither optional ID is needed for the first-run examples above.

## API reference

| Method                                               | Result                                                                     |
| ---------------------------------------------------- | -------------------------------------------------------------------------- |
| `run(stringOrInput, options?)`                       | Submit and wait for successful completion.                                 |
| `with({ workspaceId?, agentId? })`                   | Independent client defaults.                                               |
| `runs.create(input, options?)`                       | Accepted task.                                                             |
| `runs.get(id, options?)`                             | Current snapshot.                                                          |
| `runs.downloadArtifact(id, artifactId, options?)`    | Project ZIP as `Uint8Array`; SDK 1.1.0+.                                   |
| `runs.list(workspace?, options?)`                    | At most 500 recent runs; no pagination.                                    |
| `runs.result(id, options?)`                          | Wait for success; optional output parser.                                  |
| `runs.wait(id, options?)`                            | Return stopped work; wait through a completed model's pending publication. |
| `runs.events(id, options?)`                          | Async iterator over persisted events.                                      |
| `runs.cancel(id, options?)`                          | Cancellation request and current snapshot.                                 |
| `runs.followUp(id, { prompt, runnerId? }, options?)` | New run with previous context.                                             |
| `runs.answer(id, { requestId, answer }, options?)`   | One linked continuation; SDK 1.3.0+.                                       |
| `runs.recover(id, options?)`                         | Bounded environment recovery; SDK 1.3.0+.                                  |
| `runs.retry(id, options?)`                           | New run; optional `runnerId`.                                              |
| `agents.list(workspace?, options?)`                  | Saved agents.                                                              |
| `parseWebhook(options)`                              | Verified event; conversation/update types require the SDK 1.3.0+.          |
| `verifyWebhook(options)`                             | Boolean signature check.                                                   |

List methods accept a workspace string or `{ workspaceId }`, or use the client default. HTTP options are `signal`, `requestTimeoutMs`, and `maxRetries`. Submissions additionally accept `idempotencyKey`. Wait/result/event options add `intervalMs` and `timeoutMs`; results accept `parse`, and events accept `after`. `run` supports both result and submission options.

`TaskInput` supports `agentId`, `workspaceId`, `prompt`, `input`, `provider`, `model`, `reasoning`, `connectionIds`, `metadata`, `outputSchema`, `timeoutSeconds`, `runnerId`, `parentTaskId`, and `notification`. Prefer `runs.followUp` for prior context. Import public types from the package root; `Run` aliases `Task`, and `CompletedRun<T>` narrows successful structured results.

## Errors

| Error                     | Handling                                                                                     |
| ------------------------- | -------------------------------------------------------------------------------------------- |
| `NightjarError`           | Inspect HTTP `status`, optional `code`, `requestId`, and `retryAfterMs`.                     |
| `NightjarConnectionError` | Check network/base URL; inspect `cause`.                                                     |
| `NightjarProtocolError`   | Unexpected response shape; check SDK/server compatibility.                                   |
| `NightjarRunError`        | Inspect `run.lifecycle`, `run.attention`, `run.error`, and deliverable errors in `run.apps`. |
| `NightjarTimeoutError`    | Resume waiting with `taskId` or cancel explicitly.                                           |
| Request `TimeoutError`    | Recover uncertain submissions using the original body/key.                                   |
| `NightjarWebhookError`    | Check raw bytes, signature, secret, clock, and event shape.                                  |
| `NightjarOutputError`     | The runtime parser rejected the completed result; inspect `run` and `cause`.                 |

For `model_account_required`, connect the key creator's own account. For `connection_not_ready`, recheck or reconnect the selected tool. `idempotency_conflict` requires the original request body or a new key for a new job. `queue_full` means wait for current work to finish. Administrative operations require a signed-in browser session.

For `question_not_current`, `answer_conflict`, or `continuation_exists`, reread the run and use its current question or linked continuation. `execution_unconfirmed` means wait for cleanup or recovery before answering or starting another attempt. `recovery_not_required` means the state changed before your request; reread it instead of retrying recovery.

## Environments and execution limits

An Environment is the execution setup holding native model sign-ins, installed tools, and working files. The SDK can use the ready Environment for your account without a separate setup call.

New runs have native shell and file tools in a persistent directory dedicated to their conversation. Continuations reuse it. Legacy Prized runs retain `~/workspace` and remain exclusive. Workers provide Node.js 24, pnpm, Python 3, Git, build tools, curl, ripgrep, and unzip. Private HTTPS Git clones use the selected connection; pushes require write access. SSH, Git LFS, workflow-file permission, and managed browser/computer use are unsupported.

**The VM is the trust boundary.** Full-permission code can access worker files and model authentication; selected connections cannot constrain credentials independently installed there. Do not share one worker between mutually untrusted users or secrets. Control-plane credentials stay on the server. Cancellation cannot undo writes or guarantee cleanup of deliberately hostile code.

Managed pilot Environments and explicitly enrolled legacy Environments disable idle pause within their original finite lease. The provider idle heuristic is not reliable evidence that an agent has stopped. Recovery can wake the exact existing Environment and restore its earliest hard deadline; it cannot extend it. Files and native sign-ins persist; disk storage remains billable.

Cloud concurrency defaults to one. The operator can set `NIGHTJAR_CLOUD_CONCURRENCY=2` for two independent conversations after measuring the workload. Same-conversation work, legacy shared directories, and local execution remain sequential; uncertain execution reserves the entire Environment. Each isolated cloud run has a 1,280 MiB process RSS guard and conservative build settings. These controls are scheduling safeguards, not security isolation or a hard OS memory reservation.

## Upgrade from 0.2.0

Change imports from `nightjar-sdk` to `@silicon-intern/nightjar`, or preserve them with an alias:

```sh
npm install 'nightjar-sdk@npm:@silicon-intern/nightjar@^1.0.0'
```

Existing named imports, explicit client options, `runs` methods, types, and `verifyWebhook` remain. `runs.wait` still returns unsuccessful stopped runs. Legacy `createTask`, `getTask`, `cancelTask`, and `listTasks` remain; `getTask` retains `{ task, events }`. Environment-variable defaults are new: keep explicit options when ambient variables should not choose your workspace or agent.

The original hosted `nightjar-sdk@0.2.0` archive remains at `/downloads/nightjar-sdk-0.2.0.tgz`, and the scoped 1.0.0 archive remains at `/downloads/silicon-intern-nightjar-1.0.0.tgz`. Both are frozen for existing lockfiles.

## Validate an integration

Submit a harmless goal with a stable job ID, repeat the same body/key to confirm one run, and check its real result. Then submit a connected read with explicit `connectionIds` and confirm the result uses the intended resource. Verify signed task and test webhooks, including duplicate delivery. Handle failures and resume a local timeout by run ID. Test with each provider your application supports. Scheduling is optional.

## Instructions for coding agents

Read this guide and the installed TypeScript declarations before integrating. Use `runs.create` with a stable idempotency key for HTTP handlers, save run IDs, and collect results through workers or verified webhooks. Keep keys out of browser code, prompts, and metadata. Handle unsuccessful runs explicitly and validate structured output at runtime.

Use injected `fetch` transports for local tests and report real cloud validation separately. This guide ships as `node_modules/@silicon-intern/nightjar/GUIDE.md`; its copy matches the installed package, while hosted docs are deployed separately.

## Package maintenance

Maintainers: `pnpm build:sdk` builds ESM/CommonJS and declarations, copies this guide, packs the package, and generates hosted docs. Run `pnpm check` and SDK tests before release. Preserve the legacy archive. An npm publication and a hosted deployment are separate operations.

## Phone assistants and interactive pages

SDK **1.4.0** adds a durable conversation feed and typed interactive answers. With a compatible assistant, the loop is: dictate a goal → delegate → receive a saved question → speak an answer or open its form → continue → receive the result. Nightjar does not provide microphone capture, transcription, calls, or background phone execution. Those belong to the assistant/channel. An open MCP chat alone does not guarantee proactive delivery after the chat closes.

For assistants without MCP, use the direct HTTP integration contract below. HTTP execution alone is insufficient: verify persistent credential access and a background observer that survives restarts. Do not substitute dashboard browser automation for a requested API integration. For Instinct specifically, the September 20 account probe reported shell HTTP support, but no vault-to-shell secret injection or shared persistent files for scheduled agents, and no general inbound webhook endpoint. An authenticated, durable Instinct HTTP integration has therefore not been verified. Do not invent a callback endpoint or promise unattended delivery from an ephemeral shell. Nightjar's signed-in mobile dashboard remains available for the owner to read and answer saved questions directly.

Give the assistant this instruction:

> Delegate this work through Nightjar. Ask me when a decision is necessary; use an interactive form when useful. Save the conversation ID and cursor. Check get_conversation for pending questions, wait for lifecycle.canAnswer, and ask me the exact question. Use answer_run with the exact requestId; for a form include values and interactionVersion. Keep observing the returned continuation. Never invent my answer.

### Recoverable observation

```ts
const run = await nightjar.runs.create({ prompt: 'Build a small project. Ask me to choose the design.' }, {
  idempotencyKey: savedJobId,
});
// Save run.id before returning from your HTTP handler.
for await (const event of nightjar.conversations.watch(run.id, {
  after: savedCursor, signal: shutdown.signal,
})) {
  const snapshot = await nightjar.runs.get(event.runId);
  // In ONE database transaction: deduplicate event.id, save the snapshot,
  // enqueue any notification, and commit event.sequence as your cursor.
  await processAndCommit(event, snapshot);
}
```

`conversations.get(id)` returns up to 100 recent run summaries (including attention/lifecycle); `truncated` reports older history. `conversations.events(id,{after,limit,waitSeconds})` returns ordered events, `nextCursor`, and `hasMore`. IDs may be either a run ID or its conversation ID. `after` is a saved nonnegative event sequence; gaps are normal. Page sizes are 1–100 and long polls 0–20 seconds. Events contain IDs and types, not full outputs; fetch the run when needed. The shared event ID deduplicates a webhook and a feed delivery. Authentication and workspace authorization are checked again after every long poll.

`conversations.watch` follows **all** linked continuations, including branches, across human pauses. It ends only when aborted, closed, or a nonretryable error occurs. It retries transient connection/server/rate-limit failures and honors Retry-After. Save your cursor only after durable processing. A read timeout or closing the iterator does not cancel a run. Do not run a permanent watcher in a phone browser or a short-lived HTTP request; use your backend worker. Events remain in this single-host pilot's SQLite database; they are not a geographically replicated log or an unlimited retention SLA.

### Interactive questions

Agents use their built-in `request_input` tool. A structured question contains `interaction: {title, fields, html?}`. Fields support text, textarea, number, boolean, and select; optional `required`, numeric `min`/`max`, and select `options` are validated. Up to 24 fields and 180,000 HTML characters are allowed. HTML is immutable for a saved question and is omitted from task snapshots/webhooks. Ordinary text questions and choices still work.

The signed-in run page displays the optional HTML in an opaque sandbox. Generated code may call `window.nightjar.setValues({theme:'Ocean', days:7})` to propose values and `window.nightjar.resize(300)` to request a bounded height. The user chooses **Use preview choices**, reviews the trusted form, then presses **Send answer and continue**. Generated code cannot send an answer, read Nightjar cookies, call APIs, load external assets, navigate the parent, or invoke tools. Embed CSS/JS and data images in the HTML. Form drafts stay on that user's browser. A normal typed form remains available if the custom preview fails.

An assistant can also collect those same fields by voice and submit them server-side:

```ts
const current = await nightjar.runs.get(runId);
const question = current.attention;
if (current.lifecycle?.canAnswer && question?.interaction) {
  const continuation = await nightjar.runs.answer(runId, {
    requestId: question.id,
    interactionVersion: question.interaction.version,
    values: { theme: 'Ocean', days: 7 }, // The user's actual answers.
  });
  // Save continuation.id. Its conversationId stays stable.
}
```

Use `values` alone for a form and `answer` for a text question. Unknown fields, invalid choices/types/ranges, missing required values, and stale versions are rejected. The same normalized answer returns the same continuation, including after a lost HTTP response. A different answer after acceptance returns a conflict. The server commits the accepted answer, continuation, and events atomically. Continuations retain the workspace files and bounded prior context; they are new provider runs, not exact native session restoration.

Interactive questions are private and require Nightjar sign-in. Independently published static/Vite deliverables still produce shareable public links through the existing publishing flow; a public page has no Nightjar credentials and cannot submit private answers. Use the private run link for the authenticated question flow.

### Executable channel bridge

`scripts/examples/conversation-backend.ts` now uses `conversation-inbox.ts` for a persistent SQLite cursor, event deduplication, notification outbox, and downstream receipts. It polls every five seconds; verified webhooks trigger an earlier reconciliation. No webhook subscription is required for polling recovery. Start it with Node 24 and the environment variables documented above, plus a persistent `EXAMPLE_DB` path. The example is a **single trusted-user backend**, bound to loopback; replace its demo token auth with per-user/channel ownership and CSRF/reply-authentication controls before exposing it publicly.

- `POST /jobs` with `Idempotency-Key`: save a goal and start tracking its conversation. Retry the exact body/key after uncertainty, including a crash between remote creation and local persistence.
- `GET /messages`: fetch up to 100 pending questions/results; reading does not mark delivery. Join each relative `link` to your Nightjar base URL.
- Deliver each message through the real channel using `message.id` as its idempotency key. Then `POST /messages/:id/ack` with `{receipt: 'channel-message-id'}`. Duplicate identical acknowledgments are safe. If the channel lacks idempotent sends, rare duplicate notifications after a lost acknowledgment remain possible.
- `POST /jobs/:runId/answer`: pass the user's exact `requestId` and `answer`, or `values` plus `interactionVersion`. Persist inbound channel callbacks before acknowledging them, retry uncertain answers with the same body, and route by explicit user/conversation/question IDs.

The adapter must prove one complete loop on the real device: disconnect/restart the backend, recover a question, deliver it once or visibly deduplicate it, receive the authenticated phone reply, accept the answer once, and receive the continuation result. Local fake transports do not prove Instinct delivery.
