Skip to main content
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 and 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
  • 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 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. 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:

Authentication and TERSE_API_KEY

Terse, TerseAgent, and other SDK calls that reach Terse send Authorization: Bearer … from process.env.TERSE_API_KEY. Terse API tokens use the terse_ prefix and come in two flavors. User tokens are the API tokens you create in the Terse app or get from terse auth login. Use them for local runs, the CLI, and self-hosted data plane handleTrigger servers. They authenticate to the full set of SDK routes your workflow needs, including deploy, codegen, and runtime calls. Project-scoped tokens are minted automatically inside the Terse Cloud data plane’s Modal sandboxes. They are limited to runtime endpoints for that project (agent runs, tool execution, approvals, and session streams) and cannot stand in for a user token on organization or integration-management routes. The control plane removes the sandbox token when the run completes. 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.
  • 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.
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. 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.

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.

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.
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.
For deterministic calls (a fixed Slack message, a known field update), use toolbox.* 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().

TerseAgent

TerseAgent is the lower-level primitive that 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).
Use run when you want to stream output progressively.

runAndWait(userMessage)

Waits for the model to complete and returns the final output string.
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.

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.
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.
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.

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.
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().

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.

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.

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. result.choice is typed to the union of your option ids. See 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 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.
Re-run terse generate when your integration context changes. Do not edit src/terse.generated.ts by hand.