- name
- jig-writing
- description
- How to write jigs using the Jig SDK. Use when creating new jigs, refactoring existing ones, or deciding how to structure a workflow. Covers jig(), llm(), agent(), connections, and the core principle of compiling what you can while keeping agent() for fuzzy judgment.
# Writing Jigs
Jigs are deterministic units of work with LLM "hatches" — points where intelligence
is needed for content generation, judgment, or fuzzy data gathering. Everything else
is code.
```
event → code → [agent hatch] → code → [llm hatch] → code → result
fuzzy content deterministic
```
The goal: minimize what the LLM does at runtime. If you can hardcode it, hardcode it.
## SDK
### `jig(name, options, handler)`
Define a jig. The `tools` array is the permission boundary — nothing outside it can be called.
```typescript
import { jig, llm, agent } from "@jig/sdk"
import { granola } from "@jig/connections/granola.js"
import { workspace } from "@jig/connections/workspace.js"
const myJig = jig("my-jig", {
tools: [granola.list_meetings, workspace.gmail_createDraft],
}, async (ctx) => {
const company = "Acme"
const recipient = "client@example.com"
// ...
})
export default myJig
```
Options: `trigger` (required, see rule 8), `tools`, and three optional runtime settings that
only exist in the source: `model` (OpenRouter id, the default for every `llm()`/`agent()` in
the jig), `runTimeoutMs` (whole-run watchdog, default 30 minutes) and `toolTimeoutMs`
(ceiling per MCP tool call, default 5 minutes). See rule 12b for precedence.
If a jig does not need user-supplied inputs, omit `params` entirely. Do not add placeholder
or speculative params just because a jig could theoretically be configurable.
### `run(definition, params)` — called by the CLI, not by jig files
Jig files just `export default myJig`. The CLI imports and calls `run()`.
```typescript
// In jig files: just export
export default myJig
// The CLI handles execution, param prompting, dry-run, etc.
```
### `llm(prompt, data, options?)`
Content generation or judgment. No tool access. Deterministic for the same input.
```typescript
// Plain text
const email = await llm("Write a summary", { meetings, commits }) as string
// Structured output
const result = await llm("Classify this", { text }, {
schema: { priority: "string", urgent: "boolean" }
})
// → { priority: "high", urgent: true }
// Lists of objects: one-element array declares the item shape
const review = await llm("Review this", { text }, {
schema: {
score: "number",
notes: [{ title: "string", detail: "string" }],
}
})
```
Schema values are JSON Schema type names (`string`, `number`, `integer`,
`boolean`, `object`, `array`, `null`), a nested object, or a one-element array.
There is no `"any"`: providers run these in strict mode and reject it with a 400
*after* the model has generated the answer, so the work is done and discarded.
Describe the real shape instead.
Other options: `model` (OpenRouter id for this one call; wins over the step and
jig models, see rule 12b) and `maxTokens` (default 64000; reasoning models spend it
thinking before they answer. A reply left empty by an exhausted budget is retried
once with 3x the budget before the step fails).
### `agent(prompt, tools, options?)`
LLM with bounded tool calling. The agent decides which tools to call, how many
times, and in what order, but only from the tools you give it.
```typescript
const data = await agent(
"Find all meetings and emails about this project from last week",
[granola.query_granola_meetings, workspace.gmail_search, workspace.gmail_get]
)
```
Options match `llm()`: `schema`, `model` (this call only) and `maxTokens`.
This is the "bigger hatch" — use it when the task requires judgment about
*how* to gather or process data. The agent might search, read results, search
again with a refined query, etc.
### `ctx.output(...args)`
Write output. Never use `console.log()` in jig handlers — always `ctx.output()`.
Output is captured by the runtime for dry-run, dashboard display, and testing.
```typescript
ctx.output(result.email)
ctx.output(`Draft: https://mail.google.com/mail/u/0/#drafts/${id}`)
```
### `ctx.memory` — remember things across runs
A persistent per-jig key/value store. Scoped to the jig: one jig can neither
read nor overwrite another's keys. Values round-trip as JSON.
```typescript
await ctx.memory.set("todo:42", { title: "Renew passport", dueAt })
const item = await ctx.memory.get<Todo>("todo:42") // null if never written
const open = await ctx.memory.list<Todo>("todo:") // [{ key, value }], key order
await ctx.memory.delete("todo:42") // true if one was there
```
Use it for anything the jig must know that its own last run learned: a to-do
list, ids already processed, a running tally, the last cursor fetched. It is
NOT for data you can re-fetch from a connection — read that live instead.
Namespace keys with a prefix (`"todo:"`, `"seen:"`) so `list(prefix)` gives you
one logical collection back. A jig storing records should key them by a stable
id from the source, not by an incrementing counter.
Limits: 64KB per value, 1000 keys per jig. A jig that writes a *new* key every
run will eventually hit the cap — that is the signal to key by something stable
or delete what is finished. Writes no-op during a dry run; reads return real
data so the preview reflects true state.
### `ctx.once(key, fn)` - do something exactly once
Runs `fn` the first time this jig sees `key`, and never again.
```typescript
for (const meeting of meetings) {
await ctx.once(`coached:${meeting.id}`, async () => {
const transcript = await granola.get_meeting_transcript({ meeting_id: meeting.id })
await ctx.email({ subject: `Notes: ${meeting.title}`, text: await llm("Coach me", { transcript }) as string })
})
}
```
This is what makes a polling jig safe. A jig on a 15-minute cron sees the same
item on every tick. Without a record of what it already handled it acts on each
one over and over, and the user gets the same email four times an hour.
**Prefer this over a time window whenever the source has stable ids.** Rule 12's
window trick (match each item to exactly one cron bucket) is the fallback for
when it does not: a window wide enough to survive a late tick is also wide
enough to fire twice. Some sources have no end time to build a window from at
all, and there `ctx.once` is the only option.
The key is recorded *before* `fn` runs, so a crash midway costs one missed item
rather than repeating a side effect that already happened. If `fn` throws, the
key is released and the next run retries it. Returns true when `fn` ran, false
when the key had been seen before. During a dry run `fn` runs and nothing is
recorded.
Keys count against the same 1000-key budget as `ctx.memory`, so scope them to
something that ages out (`coached:<meeting-id>`), not something unbounded.
### `ctx.remind(at, payload, options?)` — wake this jig up later
Schedules a one-off future run of this jig. Use it when the time to act is
decided by the data, not by a fixed schedule: a to-do's due date, a follow-up in
three days, a deferred retry.
```typescript
await ctx.remind(dueAt, { title }, { key: "todo:42" })
const pending = await ctx.reminders() // [{ id, key, dueAt, payload }]
await ctx.cancelReminder("todo:42") // true if one was pending
```
`at` takes a `Date`, epoch milliseconds, or an ISO string.
**Always pass `options.key` when the reminder is about a specific thing.** With
a key, re-reminding *replaces* that key's pending reminder instead of stacking a
second one — so a jig that re-reads the same to-do on every run reschedules
rather than sending a duplicate every time. Without a key, every call is a new
independent reminder.
When reminders come due, the scheduler starts the jig with them in
`ctx.params.reminders` — always an **array**, because more than one can come due
at once, and they arrive in one run rather than one run each:
```typescript
const due = (ctx.params.reminders ?? []) as Array<{
key: string | null
payload: unknown
dueAt: string // ISO
}>
```
So a jig that both accepts new items and fires reminders branches on which
happened:
```typescript
if (due.length > 0) { /* reminders fired */ }
else if (ctx.params.email) { /* new item arrived */ }
```
Reminders fire for any trigger type, including `manual` — they are the only
thing that wakes a manual jig on its own. A paused jig holds its reminders and
delivers them when re-enabled rather than dropping them. `ctx.remind` no-ops
during a dry run.
Prefer this over a frequent cron that scans for due work: it fires at the exact
time, and it does not spend 288 runs a day to send two reminders.
### `ctx.parallel(...promises)`
Run multiple promises concurrently. Sugar over `Promise.all` with proper typing.
```typescript
const [meetings, emails] = await ctx.parallel(
granola.list_meetings({ time_range: "last_week" }),
workspace.gmail_search({ query: company }),
)
```
### Connections
Import from `@jig/connections/`. Auto-generated after `jig connect <server>`.
Tools connect lazily on first call — no setup code needed.
```typescript
import { granola } from "@jig/connections/granola.js"
import { workspace } from "@jig/connections/workspace.js"
import { github } from "@jig/connections/github.js"
```
---
## Jig Writing Rules (must follow)
These rules are enforced by the runner and the static validator. Violations
cause the jig to refuse to run with a clear error.
### 1. Maximize determinism (most important)
Prefer direct tool calls > `llm()` > `agent()`:
- Direct call when you know the tool + params at write time
- `llm()` for synthesis, writing, or classification from known data
- `agent()` only when the sequence of tool calls requires runtime judgment
- Default to breaking large workflows into deterministic steps instead of one giant `agent()`
### 2. Structure with `ctx.step()` blocks (REQUIRED, enforced)
**Every jig MUST wrap its work in at least one `ctx.step()` block.** Tool calls
outside a step throw at runtime. Jigs with zero steps are rejected by the
validator.
`ctx.step()` is purely a labeled scope. **Do NOT assign its return value** —
pass data between steps using `let` variables in the outer handler scope.
```typescript
let events: any[] = []
await ctx.step("Get calendar events", [calendar_listEvents], async () => {
events = await calendar_listEvents({...})
ctx.output(`Found ${events.length} events`)
})
await ctx.step("Send Telegram", [telegram_send_message], async () => {
await telegram_send_message({ chat_id: CHAT_ID, text: format(events) })
})
```
Rules:
- Wrap every logical group of tool calls in a `ctx.step()` block
- Each step declares exactly which tools it uses — only those tools work inside the block
- Every connection tool used inside a step callback MUST appear in that same step's tools array. If you call `apify.call_actor` and then `apify.get_actor_output`, either declare both tools in that step or, preferably, split them into two sequential steps.
- If you violate the step tool allowlist, runtime fails with an error like: `Tool "apify.get-actor-output" is not allowed in step "Scrape GitHub trending repos". Declare it in the tools array of your ctx.step() call.`
- **Steps MUST be flat — never nest `ctx.step()` inside another `ctx.step()` callback. The runtime AND the static validator throw if you do.**
- Conditional early-return is fine, but the early-return path must NOT contain another `ctx.step()` — finish the current step, then branch in the outer handler.
- `agent()` and `llm()` calls are always allowed inside any step — they don't need to be in the tools array
- Step labels MUST be static strings — never use template literals with runtime variables. Good: `"Research meeting context"`. Bad: `` `Research: ${eventSummary}` ``
- ALL `ctx.output()` calls must be inside `ctx.step()` blocks — output is tied to the step it belongs to
- Use `ctx.output()` inside steps to show progress
### 3. Do NOT declare jig params
- Do NOT declare `options.params` in a jig. Human-configured jig params are no longer supported.
- If the request already specifies the value, hardcode it.
- If you need a constant that the user did not provide, ask the user, then hardcode their answer.
- `ctx.params` still exists at runtime for incoming webhook/manual payloads passed in externally. Use it only for intrinsic runtime payload data, not for declared jig configuration.
### 4. Hardcode constants, don't discover them at runtime
- If the jig needs a value that is constant across runs (the user's email, name, team, Slack channel, timezone, recipient list, etc.), prefer hardcoding it over discovering it at runtime
- If you don't know a constant, ask the user before writing code. Keep the question short: "What email should I send the briefing to?" Then hardcode their answer.
- Do not turn those constants into jig params
- When an LLM analyzes the user's own data (inbox, calendar, DMs, files), pass identity into the prompt (`"the user = <name> <email>"`) **and** filter self-originated records at the source (`-from:me`, organizer-equals-me, etc.) — otherwise the LLM will suggest you reply to your own sent mail
### 5. Use the right tools
The available tools and probe results show what's available. Use multiple relevant tools when they materially improve the result.
If the requested workflow depends on a specific connection, MCP server, or tool, the jig MUST actually use it. Do not degrade into a no-op jig that only explains setup steps.
Forbidden patterns:
- Do NOT tell the user to run `jig connect ...` from inside jig output
- Do NOT emit placeholder copy like "Once connected, this jig will..."
- Do NOT use `llm()` to fabricate "example output" for a tool you failed to call
- Do NOT replace a missing integration with generic prose or mocked/sample results
If the required connection or tool is unavailable, stop creation/editing and surface the missing dependency instead of writing placeholder code.
### 6. Preserve the existing toolset when editing
- If you are editing an existing jig, do NOT add or remove tools unless the user explicitly asked for tool changes
- Small logic, wording, output, or scheduling edits should usually keep the existing tools unchanged
- Only change the toolset when the current tools are insufficient or invalid for the requested behavior
### 7. Edit existing jigs surgically
When editing an existing jig, treat the current file as the source of truth.
The user usually wants a narrow patch, not a rewrite.
Before writing:
- Identify the exact requested behavior change.
- Locate the smallest code region that implements that behavior.
- Preserve the existing trigger, schedule, recipients, subject lines, constants, data sources, step labels, step ordering, and output shape unless the user explicitly asks to change them.
- Preserve existing formatting modes. If a jig sends HTML, keep sending HTML. If the user asks for HTML but the schema says the field is `isHtml`, use `isHtml: true` rather than inventing unsupported fields like `contentType`.
- Preserve the current tool list unless Rule 6 says it must change.
- Do not "simplify", collapse steps, remove data gathering, replace deterministic code with `llm()`/`agent()`, or remove user-visible sections just because they seem verbose.
- Do not call `read_jig_file` if the current jig code is already in the prompt context. Use the provided code.
- After the edit, run `check_jig`. If it fails, patch the narrow error and check again.
Bad edit behavior:
```typescript
// User asked: "make the email body HTML"
// Bad: rewrite the whole jig, remove HTML conversion, shorten the workflow,
// change the subject, and send plain text.
```
Good edit behavior:
```typescript
// User asked: "make the email body HTML"
const htmlBody = markdownToHtml(markdownBody)
await workspace.gmail_send({
to: "user@example.com",
subject,
body: htmlBody,
isHtml: true,
})
```
### 8. Pick the right trigger (ASK if unclear)
Every jig has exactly one `trigger` field:
- `{ type: "manual" }` — user runs it from the CLI or dashboard button. No schedule, no external input. Use for one-off tasks, interactive workflows, or jigs triggered only by a human click.
- `{ type: "cron", cron: "0 8 * * 1" }` — runs on a schedule (5-field cron: min hour day-of-month month day-of-week). Examples: `"0 8 * * 1"` = Mon 8am, `"*/15 * * * *"` = every 15 min, `"0 9 * * 1-5"` = weekdays 9am. Use for digests, reminders, periodic sync, reports.
- `{ type: "webhook" }` — runs when an external service POSTs to `/api/webhooks/{jigId}?token=...`. The POST body becomes `ctx.params` (nested JSON). Use for incoming messages from Telegram, Slack events, GitHub webhooks, email inbound parse, etc.
- `{ type: "email" }` — runs when the user emails the jig's own address. Use when the user wants to *send things to* a jig in plain English. See "Email trigger" below.
- `{ type: "calendar", minutesBefore: N }` — runs once per upcoming meeting. See "Calendar trigger" below.
عرض على GitHub