Skip to main content
A job is the core building block in Terse. It connects an event (the trigger) to the code that should run when that event happens (the handler), with a set of integrations available to use along the way (skills). When you deploy a Terse project, you’re deploying one or more jobs. In the SDK, you define jobs with createJob() in TypeScript. In the Terse app and across these docs, we call them workflows. Same thing, different name.

Anatomy of a job

Every job has four parts:
await client.createJob({
    name: "new-deal-enrichment",
    triggers: [Attio.onRecordCreated({ list: AttioList.Pipeline.NewDeals })],
    skills: [Attio.skill({ lists: [AttioList.Pipeline.NewDeals] }), Apollo.skill(), Slack.skill({ channel: SlackChannel.DealDesk })],
    onTrigger: async (event: Trigger, agent: TerseAgent) => {
        // your logic here
    }
})

Name

A unique string identifier. Terse uses this to match the job across deploys, so if you rename it, the platform treats it as a new job. Keep names stable and descriptive.

Triggers

The event that starts the job. A trigger is always tied to an integration (Attio record created, Slack message received, GitHub PR opened) or to the system (cron schedule, webhook). A job can have multiple triggers, but each execution is started by exactly one event.

Skills

The integrations available to the model during agentic calls (run and runAndWait). Skills don’t limit what your code can do with deterministic tool calls. They only control what the LLM can reach for when it’s reasoning.

Handler

The function that runs when the trigger fires. It receives the trigger event and a TerseAgent instance. Inside the handler, you can make deterministic tool calls, run the model, parse structured output, or combine all three.

What happens when a job runs

Trigger fires → Filter (optional) → Handler executes → Run recorded in Activity
  1. Trigger fires. An event arrives from the connected integration or on the configured schedule.
  2. Filter evaluates. If you defined a filter function, Terse calls it with the event. Return false to skip the run entirely. No tokens spent, no side effects.
  3. Handler executes. Your onTrigger (TypeScript) runs with the event payload and a TerseAgent. You control the logic: call tools deterministically, hand off to the model, or both.
  4. Run is recorded. Every execution, whether it succeeds or fails, is logged in Activity with the full action trace so you can inspect what happened.

Deterministic vs. agentic

Inside a handler, you have two ways to get things done:
ApproachHowWhen to use
Deterministicagent.tools.* or executeTool()You know exactly which tool to call and with what parameters
Agenticrun() or runAndWait()You need the model to reason, summarize, classify, or decide
Most production jobs combine both. Use deterministic calls for predictable operations (fetch data, update a record, send a message) and agentic calls when the model adds value (scoring, summarizing, routing).
onTrigger: async (event: Trigger, agent: TerseAgent) => {
    // Deterministic: always enrich
    const company = await agent.tools.apollo.enrichCompany({
        domain: event.record.values.company_domain
    })

    // Agentic: let the model reason about the data
    const summary = await agent.runAndWait(
        `Summarize this company for the account owner.\nIndustry: ${company.industry}\nEmployees: ${company.employeeCount}`,
        event
    )

    // Deterministic: always write the result back
    await agent.tools.attio.updateRecord({
        list: AttioList.Pipeline.NewDeals,
        recordId: event.record.id,
        fields: { research_summary: summary }
    })
}

Filtering events

Not every event needs a full run. Use filter to skip events that don’t match your criteria before the handler executes.
await client.createJob({
    name: "contract-alerts",
    triggers: [Attio.onRecordUpdated({ list: AttioList.Pipeline.OpenDeals })],
    skills: [Slack.skill({ channel: SlackChannel.DealDesk })],
    filter: (event: Trigger) => event.record.values.stage === "contract-sent",
    onTrigger: async (event: Trigger, agent: TerseAgent) => {
        await agent.tools.slack.sendMessage({
            channelId: SlackChannel.DealDesk.channelId,
            message: `Contract sent for ${event.record.values.company_name}.`
        })
    }
})

Tool approvals

For jobs that write to production systems, you can require human approval before specific tools execute. List the tool names in toolApprovals (TypeScript). During local testing, the CLI prompts in your terminal. In production, approval requests appear in the Terse app under Notifications.

Lifecycle

Jobs follow a straightforward path from code to production:
  1. Define. For TypeScript, register workflows with top-level createJob() in src/terse.jobs.ts (or split across files and import them for side effects). For Python, define jobs in src/main.py.
  2. Generate. Run terse generate to get typed helpers for your integrations.
  3. Test. Run terse test to execute against real sample events locally.
  4. Deploy. Run terse deploy to package and host serverlessly. New jobs are created, existing jobs are updated, and removed jobs are cleaned up.
  5. Monitor. View runs, actions, and failures in the Activity tab.

Where to go next

Deterministic tool calls

Call integration tools directly from code, no LLM in the loop.

Triggers reference

Every available trigger and its event payload.