Skip to main content
Terse workflows combine two sources:
  • the public terse-sdk package
  • the generated helpers in src/terse.generated.ts

Core SDK imports

Import these from terse-sdk:
  • Terse — the SDK client
  • TerseAgent — the runtime object inside onTrigger
  • InputEvent — the base event interface
  • Specific event types: AttioRecordInputEvent, ScheduleCronInputEvent, ScheduleIntervalInputEvent, ManualInputEvent

Terse

The SDK client. Used to register workflows in src/index.ts.
const client = new Terse()

createWorkflow(...)

Defines the workflow contract.
FieldTypeDescription
namestringUnique workflow identifier. Used to match workflows across deploys.
triggersTrigger[]One or more triggers that start the workflow.
skillsSkill[]Capabilities available to the agent during the run.
toolApprovalsstring[]Tool names that require human approval before execution. See tool approvals.
filter(event) => booleanOptional function to skip the run for specific events. See filtering events.
onTrigger(event, agent) => Promise<void>The workflow handler. Called once per trigger event.
webhookURLstringOptional URL to notify after each run completes.
await client.createWorkflow({
  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: AttioRecordInputEvent, agent: TerseAgent) => {
    // workflow logic
  },
})

TerseAgent

The runtime object you receive inside onTrigger.

run(prompt, event?)

Streams the model run. Returns an async iterable of output chunks.
for await (const chunk of agent.run("Summarize pipeline risk.", event)) {
  process.stdout.write(chunk)
}
Use run when you want to stream output progressively.

runAndWait(prompt, event?)

Waits for the model to complete and returns the final output as a string.
const output = await agent.runAndWait("Score the account and explain why.", event)
Use runAndWait for most workflows where you need the full output before proceeding.

executeTool(toolName, params)

Calls a named tool directly, bypassing the LLM.
await agent.executeTool("attio.updateRecord", {
  list: AttioList.Pipeline.NewDeals,
  recordId: "rec_123",
  fields: { fit_score: 92 },
})
Use executeTool when you want guaranteed execution of a specific tool.

agent.tools.*

Generated helpers attach deterministic wrappers under agent.tools.*. These call integration actions directly, not through the LLM.
const enrichment = await agent.tools.apollo.enrichCompany({
  domain: "northstarlogistics.com",
})

await agent.tools.slack.sendMessage({
  channelId: SlackChannel.DealDesk.channelId,
  message: `Industry: ${enrichment.industry}, Employees: ${enrichment.employeeCount}`,
})
Use agent.tools.* for guaranteed side effects. Contrast with skills, which give the model discretion over when to call an integration.

Structured output

runAndWait returns a string. When you need typed data from the model, parse the output explicitly.

Simple extraction

const result = await agent.runAndWait(
  [
    "Score this company from 1-100 for ICP fit.",
    "Respond with ONLY a JSON object: { \"score\": <number>, \"rationale\": \"<string>\" }",
    `Company: ${event.record.values.company_name}`,
    `Industry: ${company.industry}`,
    `Employees: ${company.employeeCount}`,
  ].join("\n"),
  event,
)

const parsed = JSON.parse(result) as { score: number; rationale: string }

await agent.tools.attio.updateRecord({
  list: AttioList.Pipeline.NewDeals,
  recordId: event.record.id,
  fields: {
    fit_score: parsed.score,
    scoring_notes: parsed.rationale,
  },
})

Tips for structured output

  • Tell the model the exact JSON shape you expect.
  • Use JSON.parse() and handle the failure case — the model may include markdown fencing or extra text.
  • For critical writes, validate the parsed result before passing it to agent.tools.*.
const raw = await agent.runAndWait(prompt, event)

// Strip markdown code fences if present
const cleaned = raw.replace(/```json?\n?/g, "").replace(/```/g, "").trim()
const parsed = JSON.parse(cleaned) as { score: number; rationale: string }

if (parsed.score < 0 || parsed.score > 100) {
  throw new Error(`Invalid score: ${parsed.score}`)
}

Filtering events

Use filter to skip runs for events that don’t match your criteria. Return true to run, false to skip.
await client.createWorkflow({
  name: "contract-alerts",
  triggers: [Attio.onRecordUpdated({ list: AttioList.Pipeline.OpenDeals })],
  skills: [
    Attio.skill({ lists: [AttioList.Pipeline.OpenDeals] }),
    Slack.skill({ channel: SlackChannel.DealDesk }),
  ],
  filter: (event: AttioRecordInputEvent) =>
    event.record.values.stage === "contract-sent",
  onTrigger: async (event: AttioRecordInputEvent, agent: TerseAgent) => {
    await agent.tools.slack.sendMessage({
      channelId: SlackChannel.DealDesk.channelId,
      message: `Contract sent for ${event.record.values.company_name}.`,
    })
  },
})

Tool approvals

List tool names in toolApprovals 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.
await client.createWorkflow({
  name: "deal-enrichment-with-approval",
  triggers: [Attio.onRecordCreated({ list: AttioList.Pipeline.NewDeals })],
  skills: [
    Attio.skill({ lists: [AttioList.Pipeline.NewDeals] }),
    Apollo.skill(),
    Slack.skill({ channel: SlackChannel.DealDesk }),
  ],
  toolApprovals: ["attio.updateRecord", "slack.sendMessage"],
  onTrigger: async (event: AttioRecordInputEvent, agent: TerseAgent) => {
    const company = await agent.tools.apollo.enrichCompany({
      domain: event.record.values.company_domain,
    })

    const summary = await agent.runAndWait(
      [
        "Summarize this company for the account owner.",
        `Company: ${event.record.values.company_name}`,
        `Industry: ${company.industry}`,
        `Employees: ${company.employeeCount}`,
      ].join("\n"),
      event,
    )

    // These two calls will pause and wait for human approval
    await agent.tools.attio.updateRecord({
      list: AttioList.Pipeline.NewDeals,
      recordId: event.record.id,
      fields: { research_summary: summary },
    })

    await agent.tools.slack.sendMessage({
      channelId: SlackChannel.DealDesk.channelId,
      message: `New deal enriched: ${event.record.values.company_name}\n\n${summary}`,
    })
  },
})
Use toolApprovals for workflows that write to production systems during early development, or when compliance requires a human in the loop.

Events

Event typing depends on the trigger.
Trigger sourceEvent type
Attio record eventsAttioRecordInputEvent
Slack actionsTyped Slack action payloads
Scheduled cron triggersScheduleCronInputEvent
Scheduled interval triggersScheduleIntervalInputEvent
Manual triggerManualInputEvent
InputEvent is the common base interface. Use the most specific event type your trigger exposes.

Generated helpers

src/terse.generated.ts is created by terse generate. Do not edit it by hand. It exports:
  • Schedule — cron and interval trigger builders
  • Integration namespaces — Attio, Apollo, Slack, Http
  • Workspace resource constants — AttioList, SlackChannel, AttioOwner
  • Deterministic tool wrappers exposed through agent.tools.*
Re-run terse generate when your integration context changes. Do not edit src/terse.generated.ts by hand.

Bring your own API

Use the Http skill to call any REST API that Terse does not have a native integration for. This is useful for enrichment providers, internal services, or any system with an HTTP API.
import { AttioRecordInputEvent, Terse, TerseAgent } from "terse-sdk"
import { Attio, AttioList, Http } from "./terse.generated"

const client = new Terse()

await client.createWorkflow({
  name: "custom-enrichment",
  triggers: [Attio.onRecordCreated({ list: AttioList.Pipeline.NewDeals })],
  skills: [
    Attio.skill({ lists: [AttioList.Pipeline.NewDeals] }),
    Http.skill(),
  ],
  onTrigger: async (event: AttioRecordInputEvent, agent: TerseAgent) => {
    // Call any REST API directly
    const response = await agent.tools.http.request({
      method: "GET",
      url: `https://api.example.com/companies?domain=${event.record.values.company_domain}`,
      headers: {
        Authorization: `Bearer ${process.env.EXAMPLE_API_KEY}`,
      },
    })

    const company = JSON.parse(response.body)

    const summary = await agent.runAndWait(
      [
        "Summarize this company for the account owner.",
        `Data: ${JSON.stringify(company)}`,
      ].join("\n"),
      event,
    )

    await agent.tools.attio.updateRecord({
      list: AttioList.Pipeline.NewDeals,
      recordId: event.record.id,
      fields: { research_summary: summary },
    })
  },
})
The Http skill gives you full control over headers, method, body, and URL. Use it when:
  • You need an enrichment provider Terse does not have a native skill for
  • You want to call an internal API or microservice
  • You need to integrate with a webhook-based system