> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useterse.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# TypeScript SDK reference

> Reference for the terse-sdk package and the generated helpers used in Terse workflows.

Terse workflows combine two sources:

* the public `terse-sdk` package
* the generated helpers in `src/terse.generated.ts`

After `terse generate`, integration triggers and skills are grouped under top-level `Triggers` and `Skills` objects (for example `Triggers.github.onPROpened(...)`). See the [Triggers](/reference/triggers) and [Skills](/reference/skills) reference for every helper.

## Core SDK imports

Import these from `terse-sdk`:

* `createJob`, `CreateJobParameters`: register a workflow (call at module top level or from imported modules)
* `sleep`, `step`, `jobStep`, `log`, `waitForInput`, `slack`: durable helpers for timed waits, custom steps, replay-safe logging, and human input prompts. See [Durable helpers](#durable-helpers)
* `generateText`: the shorthand for agentic runs — one prompt in, final output out, with the model free to call any tool granted via `skills`. Use this for almost everything; see [`generateText`](#generatetext) below
* `Terse`, `TERSE_JOB_WEBHOOK_TRIGGER_PATH`: client and mount path for `handleTrigger` when you self-host the data plane webhook endpoint
* `TerseAgent`, `EventType`: the lower-level agent that `generateText` wraps. Reach for `TerseAgent.create()` directly only when you need to stream with `run()` or reuse one agent instance across calls; `EventType` enumerates values on streamed results
* `runWithJobContext`, `getJobContext`, `TerseJobContext`: wrap a handler with `runWithJobContext` to preserve run attribution. Outside of it, set `TERSE_BACKEND_URL` if needed (default `https://api.useterse.ai`) and optionally `TERSE_RUN_ID`
* `formatTriggerForAgent`, `debugTrigger`: helpers for plain trigger payloads

## Re-exported types

`terse-sdk` re-exports types you may need when annotating handlers or consuming run history. You don't need to import these unless you're typing data the SDK doesn't return for you.

| Group                                | Types                                                                                                                                               |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Base event interface                 | `Trigger`                                                                                                                                           |
| Integration event-type enums         | `SlackEventType`, `GitHubEventType`, `LinearEventType`, `GmailEventType`, `WorkOSEventType`, `HeyReachEventType`, `AttioEventType`, `FrequencyUnit` |
| Run history (Activity, API payloads) | `RunHistoryRecord`, `RunHistoryStatus`, `RunHistoryTrigger`, `RunHistoryDecision`, `RunHistoryAction`                                               |

All concrete trigger types, built-in (`CronTrigger`, `WebhookTrigger<TBody>`, `WebMonitorTrigger<TStructured>`, `WebMonitorTriggerFor<TSchema>`) and integration (`GithubPROpenedTrigger`, `SlackMessageTrigger`, `AttioRecordCreatedTrigger`, ...), along with tool `Params`/`Result` types, are generated into your workspace by `terse generate`; import them from `"./terse.generated"`.

For TypeScript workflows that use structured output schemas, also install and import `zod`:

```bash theme={null}
npm install terse-sdk zod
```

## Authentication and `TERSE_PROJECT_KEY`

`Terse`, `TerseAgent`, and other SDK calls that reach Terse send `Authorization: Bearer …` from `process.env.TERSE_PROJECT_KEY`. Terse API tokens use the `terse_` prefix and come in two flavors.

Project-scoped tokens are what running workflow code uses. They are limited to runtime endpoints for one project (agent runs, tool execution, approvals, and session streams) and cannot stand in for a user token on organization or integration-management routes. Inside the Terse Cloud data plane the control plane mints one per run and removes it when the run completes; on a self-hosted data plane `terse attach` prints one to put in your server's environment.

User tokens are the API tokens you create in the Terse app or get from `terse auth login`. They live in `TERSE_API_KEY` and belong to the CLI, which uses them to deploy, run codegen, and manage integrations. Local runs (`terse run`, `terse test`) have no project key, so the CLI falls back to your user token and sets `TERSE_PROJECT_KEY` from it for the duration of the run.

Tokens with an expiration stop working when they expire; the API responds with `401` and an expired-token message.

The API token list in the app shows **user** tokens only. Short-lived project tokens do not appear there.

## Trigger payloads

Trigger types exported from `./terse.generated` extend the canonical trigger object with the trigger time plus two methods used everywhere Terse turns an event into prompts or logging:

* `triggeredAt` is a `Date`: the moment Terse received the trigger. See [trigger time](#trigger-time).
* `formatForAgentRunner()` returns a string to include in agent prompts (matches how the platform formats the event for the model)
* `debugLog()` returns a one-line description for logs and CLI sample lists

Your `onTrigger` and `filter` callbacks receive these enriched objects, so you can call `event.formatForAgentRunner()` directly. For plain `Trigger` values (for example in tests), use `formatTriggerForAgent(event)` and `debugTrigger(event)` from `terse-sdk`.

### Trigger time

`event.triggeredAt` is stamped once when the trigger arrives, so it reads the same after queue delay, retries, and durable replays. Anchor date math to it rather than `Date.now()` or `new Date()`, which drift.

```typescript theme={null}
const DAY_MS = 24 * 60 * 60 * 1000
const end = new Date(event.triggeredAt.getTime() - 3 * DAY_MS)
const start = new Date(end.getTime() - 6 * DAY_MS)
```

To test window logic against a fixed date, add a top-level `triggeredAt` ISO 8601 timestamp to an event fixture passed to `terse test`.

## `createJob(...)`

Registers a workflow at load time. The CLI imports your entry file (`src/terse.jobs.ts` by default), so every `createJob()` call that runs during that import is included. The scaffolded layout defines each job in its own file under `src/jobs/` and imports it for side effects (`import "./jobs/myWorkflow"`) from the entry file.

Duplicate `name` values throw an error when the second job registers. At most **one** webhook trigger is allowed per workflow.

| Field       | Type                                            | Description                                                                                                                       |
| ----------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `name`      | `string`                                        | Unique workflow identifier. Used to match workflows across deploys and in `terse test`.                                           |
| `triggers`  | typed trigger array                             | One or more triggers that start the workflow.                                                                                     |
| `states`    | `{ key, value }[]`                              | Optional typed persistent state. `value` is a Zod schema. See [job state](#job-state).                                            |
| `filter`    | `(event, state) => boolean \| Promise<boolean>` | Optional function to skip the run for specific events. See [filtering events](#filtering-events).                                 |
| `onTrigger` | `(event, state) => Promise<void>`               | The workflow handler. Called once per trigger event.                                                                              |
| `durable`   | `boolean`                                       | Optional. When `true`, the job can use `sleep()`, `jobStep()`, and `waitForInput()`. See [Durability](/core-concepts/durability). |

To point the SDK at a self-hosted control plane, set `TERSE_BACKEND_URL` (it defaults to `https://api.useterse.ai`). To route execution to a self-hosted data plane, set `remoteServerUrl` in `terse.config.json` instead.

Pass `skills`, `toolApprovals`, and the agent `prompt` to `TerseAgent.create()` inside `onTrigger`, not on `createJob`.

```ts theme={null}
import { TerseAgent, createJob } from "terse-sdk"

import { GithubPRTrigger } from "./terse.generated"
import { Repos, Skills, SlackChannel, Triggers } from "./terse.generated"

createJob({
    name: "pr-auto-reply",
    triggers: [Triggers.github.onPROpened({ repo: Repos.MyOrg.MyRepo })],
    onTrigger: async (event: GithubPRTrigger) => {
        const agent = TerseAgent.create({
            prompt: "You summarize pull requests and post concise thread replies in Slack.",
            skills: [Skills.github({ repos: [Repos.MyOrg.MyRepo] }), Skills.slack({ channel: SlackChannel.Engineering })]
        })
        // workflow logic using agent and event
    }
})
```

## Job state

Declare persistent state with `states`, a list of `{ key, value }` where `value` is a Zod schema. `filter` and `onTrigger` receive a `state` object as their second argument. `state.get(key)` and `state.set(key, value)` are narrowed to the declared keys, with each value typed and validated against that key's schema.

Two type details worth knowing:

* `get` returns `T | undefined` for a key that may be unset. Give the schema a default (`z.number().default(0)`) and that key's `get` is typed `T` with no `undefined`.
* `set` returns the value it wrote, so you can use it directly instead of reading the key back.

State is stored as JSON in the project volume, in a namespace the agent cannot read or write. It is separate from the agent's memory.

Deployed runs and `terse test` runs each have their own copy of a job's state, so testing never touches production values. Test state persists across `terse test` invocations, which lets you exercise dedupe and cooldown logic locally; reset it with `terse test run --fresh-state` or inspect it with the [`terse state` commands](/reference/cli#state).

```ts theme={null}
import { createJob } from "terse-sdk"
import { z } from "zod"

const Profile = z.object({ name: z.string(), tier: z.enum(["free", "pro"]) })

createJob({
    name: "track-account",
    triggers: [Triggers.github.onPROpened({ repo: Repos.MyOrg.MyRepo })],
    states: [
        { key: "profile", value: Profile }, // no default
        { key: "seenCount", value: z.number().default(0) } // default → never undefined
    ],
    onTrigger: async (event, state) => {
        const count = await state.get("seenCount") // number (default applied)
        await state.set("seenCount", count + 1) // returns the written number
    }
})
```

## `generateText`

`generateText` is the shorthand for running the model. Give it a prompt and the `skills` the model is allowed to use; it runs a full agentic loop to completion and returns the final output. The model can call any tool granted via `skills` while it works. This is the call you reach for in almost every job.

```ts theme={null}
import { generateText } from "terse-sdk"

import { AttioObject, Skills } from "./terse.generated"

const summary = await generateText({
    prompt: `Score this account and explain why. ${event.formatForAgentRunner()}`,
    skills: [Skills.attio({ object: AttioObject.Deal }), Skills.web()]
})
```

Pass an `outputSchema` (a `zod` schema) to get a typed, validated object back instead of a string. `generateText` is overloaded on it: with a schema the return type is `z.infer<typeof schema>`, without one it is `string`.

```ts theme={null}
import { z } from "zod"

const scored = await generateText({
    prompt: `Score this account and explain why. ${event.formatForAgentRunner()}`,
    skills: [Skills.attio({ object: AttioObject.Deal })],
    outputSchema: z.object({ score: z.number().min(0).max(100), rationale: z.string() })
})
// scored is typed as: { score: number; rationale: string }
```

| Field           | Type         | Description                                                                              |
| --------------- | ------------ | ---------------------------------------------------------------------------------------- |
| `prompt`        | `string`     | The instruction for the model. Include event context via `event.formatForAgentRunner()`. |
| `skills`        | skill array  | Optional. Scopes which integration tools the model may call during the run.              |
| `toolApprovals` | `string[]`   | Optional. Tool names that pause for [human approval](/core-concepts/human-in-the-loop).  |
| `outputSchema`  | `zod` schema | Optional. Returns a typed, validated object instead of a string.                         |

For deterministic calls (a fixed Slack message, a known field update), use [`toolbox.*`](#toolbox-generated) instead — no agent needed. Between `generateText` for reasoning and `toolbox` for fixed actions, you rarely touch `TerseAgent` directly.

## `Terse`

Use a `Terse` instance for `handleTrigger`, which verifies signed webhook payloads from the Terse backend and runs the matching registered workflow. You do not need `new Terse()` only to call `createJob()`.

```ts theme={null}
import { TERSE_JOB_WEBHOOK_TRIGGER_PATH, Terse } from "terse-sdk"

const terse = new Terse()

// Mount TERSE_JOB_WEBHOOK_TRIGGER_PATH on your HTTP server (Express, Hono, Next.js, etc.)
```

## `TerseAgent`

`TerseAgent` is the lower-level primitive that [`generateText`](#generatetext) wraps. Reach for it directly only when you need something `generateText` doesn't expose: streaming partial output with `run()`, or reusing one agent instance across several calls. Otherwise prefer `generateText`.

Create the agent inside `onTrigger` with `TerseAgent.create()`. Job context (session, run, API base URL) is picked up automatically from async context when the handler runs on the platform or via the CLI. The same context applies to `TerseAgent.executeTool` and to generated `toolbox` calls.

### `run(userMessage)`

Streams the model run. Returns an async iterable of result objects (`TextResult`, `FinalOutputResult`, tool events, and so on).

```ts theme={null}
import { EventType } from "terse-sdk"

for await (const chunk of agent.run("Summarize pipeline risk.")) {
    if (chunk.type === EventType.TEXT) {
        process.stdout.write(chunk.text)
    }
}
```

Use `run` when you want to stream output progressively.

### `runAndWait(userMessage)`

Waits for the model to complete and returns the final output string.

```ts theme={null}
const output = await agent.runAndWait("Score the account and explain why.")
```

Use this form when you want raw text output.

### `runAndWait(userMessage, outputSchema)`

Pass a `zod` schema to request structured output. The SDK sends the schema with the run, parses the final JSON, and validates it before returning.

```ts theme={null}
import { z } from "zod"

const ScoreSchema = z.object({
    score: z.number().min(0).max(100),
    rationale: z.string()
})

const scored = await agent.runAndWait("Score this account and explain why.", ScoreSchema)
// scored is typed as: { score: number; rationale: string }
```

### `TerseAgent.executeTool(toolName, params?)`

Static method. Calls a named tool directly, bypassing the LLM. Generated `toolbox` and `agent.tools.*` helpers use this path, so string-based and typed deterministic calls behave the same.

```ts theme={null}
import { TerseAgent } from "terse-sdk"

import { SlackChannel } from "./terse.generated"

await TerseAgent.executeTool("slack_send_message", {
    channelId: SlackChannel.DealDesk.channelId,
    message: "Pipeline digest is ready."
})
```

The same tool accepts `slackUserId` (Slack member `U…`) to send a 1:1 DM; Terse opens the conversation if one does not exist yet. If you pass both `channelId` and `slackUserId`, the message is sent to `channelId`. With codegen, prefer `SlackUser.*.userId` from `src/terse.generated.ts` for member ids.

Use `TerseAgent.executeTool` when you want guaranteed execution of a specific tool by name (for example when the name is only known at runtime).

## `toolbox.*` (generated)

`terse generate` writes a `toolbox` export in `src/terse.generated.ts` with the same typed namespaces as `agent.tools.*`. Import `toolbox` to call integration tools deterministically **without** constructing a `TerseAgent` and without listing integrations in `skills`.

## `agent.tools.*`

Generated helpers attach deterministic wrappers under `agent.tools.*`. These call integration actions directly, not through the LLM.

```ts theme={null}
import { TerseAgent } from "terse-sdk"

import { Skills, SlackChannel } from "./terse.generated"

const agent = TerseAgent.create({
    prompt: "You draft short Slack updates for the deal desk.",
    skills: [Skills.slack({ channel: SlackChannel.DealDesk })]
})

await agent.tools.slack.sendMessage({
    channelId: SlackChannel.DealDesk.channelId,
    message: "Northstar Logistics: follow up on the open proposal thread."
})
```

Use `agent.tools.*` for guaranteed side effects when you already pass `skills` to `TerseAgent.create()`. The namespaces under `agent.tools.*` are filtered to the integrations in `skills`. For the same calls **without** listing `skills`, use the generated `toolbox` instead.

## Structured output

Pass `outputSchema` to `generateText` (or `runAndWait(message, schema)` on a `TerseAgent`) when you need typed, validated JSON. Omit it when you need free-form text.

## Filtering events

Use `filter` to skip runs for events that don't match your criteria. Return `true` to run, `false` to skip.

```ts theme={null}
type ContractPayload = { record: { values: { stage: string; company_name: string } } }

createJob({
    name: "contract-alerts",
    triggers: [Triggers.webhook.onRequest<ContractPayload>()],
    filter: event => event.body.record.values.stage === "contract-sent",
    onTrigger: async event => {
        const agent = TerseAgent.create({
            prompt: "You notify the deal desk when contracts are sent.",
            skills: [Skills.slack({ channel: SlackChannel.DealDesk })]
        })
        await agent.tools.slack.sendMessage({
            channelId: SlackChannel.DealDesk.channelId,
            message: `Contract sent for ${event.body.record.values.company_name}.`
        })
    }
})
```

## Tool approvals

List tool names in `toolApprovals` on `TerseAgent.create()` to require human approval before those tools execute. During local testing, the CLI prompts in the terminal. In production, approval requests surface in the Terse app under **Notifications**.

```ts theme={null}
createJob({
    name: "slack-digest-with-approval",
    triggers: [Triggers.slack.onMessage({ channel: SlackChannel.DealDesk })],
    onTrigger: async event => {
        const agent = TerseAgent.create({
            prompt: "You draft a short reply when someone posts in Deal Desk.",
            skills: [Skills.slack({ channel: SlackChannel.DealDesk })],
            toolApprovals: ["slack_send_message"]
        })

        const summary = await agent.runAndWait(
            ["Summarize this thread in one sentence for the on-call owner.", `Channel: ${event.channelName}`, `From: ${event.userName}`, `Text: ${event.text}`].join("\n")
        )

        // Pauses until a human approves in the CLI (local) or Notifications (prod)
        await agent.tools.slack.sendMessage({
            channelId: SlackChannel.DealDesk.channelId,
            message: `Suggested reply based on: ${summary}`
        })
    }
})
```

Use `toolApprovals` for workflows that write to production systems during early development, or when compliance requires a human in the loop.

## Durable helpers

These functions are only available when `durable: true` is set on the job. Calling them in a non-durable job throws `DurableOnlyError`.

### `sleep(duration)`

Suspends the run until the timer fires. Accepts `ms`-style strings (`"30s"`, `"5m"`, `"1h"`, `"3d"`), a millisecond number, or a `Date`. Locally (`terse test`, no `TERSE_RUN_ID`), the wait is skipped and a log line notes what production would have done.

### `step(call)`

Wrapped around a direct call to a module-scope client or function, runs that call as a durable step: `step(resend.emails.send({...}))`. Arguments evaluate in the handler and cross the boundary as serialized data; the resolved value is journaled. See [Durability — Making your own calls durable with step()](/core-concepts/durability#making-your-own-calls-durable-with-step).

### `jobStep(...)`

The fully explicit form of `step()`: declared `input`, zod validation at the boundary, no restrictions on the step body. See [Durability — jobStep: the fully explicit form](/core-concepts/durability#jobstep-the-fully-explicit-form).

### `log(...args)`

Replay-safe logging: `await log("processed", count)`. In a durable job the call is a journaled step, so the line prints exactly once instead of once per replay like a bare `console.log` outside a step. Arguments cross the step boundary, so they must be serializable data. Unlike the other durable helpers, `log()` also works in non-durable jobs, where it simply forwards to `console.log`. See [Durability — Logging](/core-concepts/durability#logging).

### `waitForInput(...)`

Posts an interactive input request and suspends until a human responds. Returns a typed `InputResponse` with `choice`, optional `text` (when the selected option has `freeText: true`), `respondent`, and `delivery`.

| Field     | Type                     | Description                                                                                                                     |
| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `via`     | `InputTarget`            | Where to deliver the prompt. Use `slack({ channel })` with a channel id.                                                        |
| `prompt`  | `string`                 | The question shown to the respondent.                                                                                           |
| `details` | `Record<string, string>` | Optional key/value pairs rendered alongside the prompt.                                                                         |
| `media`   | `InputMedia[]`           | Optional attachments, max 10. Each is `{ kind: "image" \| "video", url, altText? }`; URLs must be reachable without Terse auth. |
| `options` | `InputOption[]`          | Action buttons. Each has `id`, `label`, optional `description`, and optional `freeText`.                                        |

`result.choice` is typed to the union of your option ids. See [Durability — Waiting for human input](/core-concepts/durability#waiting-for-human-input).

Locally (`terse test`), Terse posts the same Slack message it would in production, marked as coming from `terse test`, and the run waits for the response there. The wait is journaled as a step, so durable replays return the cached answer instead of asking again.

### `slack({ channel })`

Target constructor for `waitForInput`. Pass a Slack channel id (for example `SlackChannel.DealDesk.channelId` from generated helpers).

## Events

Event typing depends on the trigger.

| Trigger source           | Event type                                                                                               |
| ------------------------ | -------------------------------------------------------------------------------------------------------- |
| GitHub events            | A `Github*Trigger` matching your helper, or `GithubTrigger` when using `Triggers.github.trigger()`       |
| Slack events             | `SlackMessageTrigger`, `SlackAppMentionTrigger`, `SlackReactionAddedTrigger`, or `SlackTrigger` (union)  |
| Linear events            | `LinearIssueCreatedTrigger`, `LinearIssueUpdatedTrigger`, `LinearCommentCreatedTrigger`, `LinearTrigger` |
| Gmail events             | `GmailTrigger`                                                                                           |
| Attio events             | The matching `Attio*Trigger` for your helper, or `AttioTrigger` when using `Triggers.attio.trigger()`    |
| HeyReach outreach events | The matching `HeyReach*Trigger` for your helper, or `HeyReachTrigger` (union)                            |
| WorkOS events            | The matching `WorkOS*Trigger` for your helper, or `WorkOSTrigger` (union)                                |
| Scheduled cron triggers  | `CronTrigger`                                                                                            |
| Manual trigger           | `Trigger` (the base event type from `terse-sdk`)                                                         |
| Webhook HTTP requests    | `WebhookTrigger<TBody>`                                                                                  |
| Web monitor              | `WebMonitorTrigger<TStructured>` (use `WebMonitorTriggerFor<typeof schema>` when annotating manually)    |

`Trigger` is the common base interface. Use the most specific trigger event type your trigger exposes. For webhooks, use `Triggers.webhook.onRequest<YourBodyType>()` from `terse.generated` so `event.body` matches the JSON you POST to the webhook URL.

## Generated helpers

`src/terse.generated.ts` is created by `terse generate`. Do not edit it by hand.

It exports:

* `Triggers`: per-integration trigger builders for every connected integration, plus the always-available `Triggers.schedule`, `Triggers.webhook`, and `Triggers.webMonitor`.
* `Skills`: per-integration skill factories for every connected integration, plus the built-in `Skills.web()` and `Skills.imageEdit()`.
* Workspace resource constants generated from your workspace (for example `Repos`, `SlackChannel`, `SlackUser`, `LinearTeam`, `LinearProject`, `NotionDatabase`, `PosthogProject`, `DatadogIndex`, `LaunchDarklyProject`, `HeyReachCampaign`, and `AttioObject.*`).
* `PosthogEventName`: a union of the custom event names observed in your PostHog project over the last 180 days, so `eventName` filters on `searchPosthogEvents` are checked at compile time.
* `toolbox`: deterministic tool wrappers you can call without constructing an agent or listing `skills`.
* `agent.tools.*`: the same wrappers attached to a `TerseAgent`, filtered by the `skills` you passed.

<Warning>Re-run `terse generate` when your integration context changes. Do not edit `src/terse.generated.ts` by hand.</Warning>
