Skip to main content
Self-hosting in Terse means your workflow code runs in your infrastructure. The createJob() registry and every onTrigger closure execute inside a Node process you operate, behind your network, with your environment variables and secrets. Terse still runs the rest of the platform.
Self-hosting is about where workflow handlers execute. It is not about running your own event processor. Trigger ingestion, webhook URLs, scheduling, integration auth, signing, retries, and run history stay on Terse.

Setting it up

1

Attach the project

From the root of your existing repo, run:
terse attach
This authenticates, links the repo to a Terse project, and writes terse.config.json with self-hosted mode enabled. See terse attach for the full flow.
2

Point Terse at your server

Set remoteServerUrl to the public URL of your service:
terse.config.json
{
    "projectId": "proj_123",
    "name": "my-app",
    "selfHosted": true,
    "remoteServerUrl": "https://your-app.example.com"
}
3

Set the required environment variables

Your server needs two values from Terse, both available in the dashboard under your project’s settings:
.env
TERSE_API_KEY=tk_live_...
TERSE_SIGNING_SECRET=whsec_...
VariablePurpose
TERSE_API_KEYAuthenticates outbound calls from your handler to Terse: opening a session, executing tools, requesting approvals.
TERSE_SIGNING_SECRETVerifies that incoming trigger requests really came from Terse. Used as the HMAC key for the x-terse-signature header.
Treat TERSE_SIGNING_SECRET like any other webhook secret. Never log it, never ship it to the client, never commit it. Rotate it from the dashboard if it leaks.
Load both into your process the same way you load any other secret — .env for local development, your platform’s secret store in production.
4

Mount the trigger handler

Expose the SDK’s webhook handler on your HTTP server so Terse can deliver events. Mount it at TERSE_JOB_WEBHOOK_TRIGGER_PATH so URLs match what the platform expects.
src/server.ts
import express from "express"
import { Terse, TERSE_JOB_WEBHOOK_TRIGGER_PATH } from "terse-sdk"
import "./terse.jobs"

const app = express()
app.use(express.json())

const terse = new Terse()

app.post(TERSE_JOB_WEBHOOK_TRIGGER_PATH, async (req, res) => {
    try {
        const result = await terse.handleTrigger(req.body, req.headers)
        res.json(result)
    } catch (err) {
        res.status(401).json({ error: (err as Error).message })
    }
})

app.listen(3000)
The side-effect import of ./terse.jobs (or whichever entry file holds your createJob() calls) is what makes the registry available at request time. handleTrigger reads TERSE_SIGNING_SECRET and TERSE_API_KEY from the process env on every call. See Terse in the SDK reference for the full client surface.
5

Deploy your code, then sync workflows

Ship your service the way you normally ship Node code (Render, Fly, ECS, your own Kubernetes, …). Once it’s reachable at remoteServerUrl, run:
terse deploy
Terse reads remoteServerUrl, registers your workflows on the platform, and configures triggers to call your server instead of the Terse-hosted runtime.
You can keep using terse test, terse history, and terse replay exactly as you would with hosted workflows. They run your local code against real sample events and the stored event stream from the platform.

Identity verification

Every request Terse sends to your server is signed with HMAC-SHA256 using TERSE_SIGNING_SECRET. handleTrigger does the verification for you, but it’s worth understanding what it checks so you can debug failures and make informed decisions about middleware order.

What gets signed

Terse sends two headers on every request:
HeaderDescription
x-terse-timestampUnix timestamp (seconds) when the request was signed.
x-terse-signatureThe signature, formatted as v0=<hex> where <hex> is HMAC-SHA256 of the base string.
The base string is v0:<timestamp>:<rawBody>, hashed with TERSE_SIGNING_SECRET as the key. handleTrigger recomputes that hash on your side and rejects the request unless it matches via constant-time comparison.

What handleTrigger enforces

1

Both headers are present

Missing either header throws. This usually means a browser or other client is hitting the endpoint, or a reverse proxy is stripping headers — make sure your proxy forwards x-terse-* through unchanged.
2

The timestamp is recent

Requests older than 5 minutes are rejected to prevent replay attacks. If you see timestamp errors in production, your server’s clock is drifting — sync NTP.
3

The signature matches

The recomputed HMAC must equal the value in x-terse-signature. Any mismatch fails the request without invoking your handler.
4

The challenge handshake (first deploy)

When Terse first registers your workflows, it sends a { type: "challenge", challenge: "<token>" } payload. handleTrigger re-signs the token with TERSE_SIGNING_SECRET and returns the result. This proves your server actually holds the secret before the platform routes real triggers to it. You don’t need to handle this case yourself — handleTrigger covers both phases.
If you parse the request body before calling handleTrigger (for example with express.json()), the SDK re-serializes it with JSON.stringify to recompute the HMAC. Don’t mutate req.body between parsing and the call, or the signature will no longer match.

What you host vs. what Terse hosts

You hostTerse hosts
The Node process that loads src/terse.jobs.tsTrigger ingestion from integrations (Attio, Slack, …)
Every createJob() registrationWebhook URLs, signing, and delivery
Every onTrigger handler closureCron and interval scheduling
Every agent.tools.* call your handler makesIntegration auth and tool definitions
Private clients, DB drivers, or internal SDKs you importRun history, action traces, and approvals
The HTTP endpoint that receives signed payloadsThe control plane and dashboard
When a trigger fires, Terse signs the event and POSTs it to your server. Your server verifies the signature with handleTrigger, looks up the registered workflow by name, and runs your onTrigger closure in your process. Tool calls, agent runs, and approvals continue to flow through the Terse backend so Activity, Notifications, and history all keep working.

How a run flows when self-hosted

1

A trigger fires on Terse

An integration event arrives, a cron schedule ticks, or a public webhook URL receives a request. Terse handles ingestion, signing, and delivery.
2

Terse POSTs a signed payload to your server

The platform sends the trigger to remoteServerUrl from terse.config.json, at the path mounted by TERSE_JOB_WEBHOOK_TRIGGER_PATH.
3

`handleTrigger` verifies and dispatches

Your HTTP handler calls terse.handleTrigger(body, headers). The SDK verifies the HMAC signature against TERSE_SIGNING_SECRET, finds the registered workflow by name, and invokes its onTrigger closure.
4

Your handler runs in your environment

The closure has access to your env vars, secret stores, private network, and any code you import. Deterministic tools, agent runs, and approvals still call back into Terse so Activity stays complete.

When self-hosting is the right choice

Pick self-hosting when your workflow needs something only your environment can provide.
ReasonExamples
Private data accessInternal Postgres, Snowflake on a private link, a service mesh, an on-prem warehouse
Secrets in your own envCredentials managed in AWS Secrets Manager, GCP Secret Manager, Vault, or your platform’s vars
VPC-only servicesInternal APIs, microservices, or databases that are not reachable from public infrastructure
Compliance and data residencyCustomer data must stay in a specific region, account, or tenancy
Reusing existing app codeYou already have a Node service with shared libraries, models, or clients you want to import
Custom runtime needsSpecific Node version, native modules, large dependencies, or a long-running process you operate
If none of those apply, the default hosted deployment is simpler. There is nothing to operate, and triggers, scheduling, and runs are fully managed.

What stays the same

Even though execution moves into your infrastructure, the rest of the developer experience is unchanged:
  • createJob() is still the unit of automation. See Jobs.
  • agent.tools.* and TerseAgent work identically. See Deterministic tool calls.
  • Runs, actions, approvals, and the Activity tab keep working as expected.
  • Integration auth and skill definitions continue to come from Terse.
The only difference is the box your onTrigger runs inside.

Where to go next

Hosting & deployment

How terse deploy packages and runs hosted workflows.

`terse attach`

CLI reference for linking an existing repo in self-hosted mode.

`Terse` client

SDK reference for handleTrigger and signed payload verification.

Jobs

The shape of createJob() and the onTrigger handler.