runners
Runners are headless, schedulable AI pipelines. While Agents are interactive (chat-based), Runners execute multi-step workflows without human interaction.
Concepts
- Runner — a pipeline definition with ordered steps
- Step — a stage in the pipeline (agent, computer, condition, or webhook)
- Trigger — how a runner gets invoked (manual, API, cron, webhook, poll, bulk import, button)
- Run — a single execution of a runner
- Run Step — the result of executing one step in a run
Architecture
Trigger (cron / webhook / API / manual)
→ POST /api/runners/{id}/runs (creates run, status='pending')
→ runRunnerPipeline(runId) (lib/runner-engine.ts)
for each step:
1. Resolve input via JSONPath input_mapping
2. Execute step (agent→Tensorlake, webhook→HTTP, condition→evaluate)
3. Write result to runner_run_steps
4. Merge output into run context
Mark run completed
Key principle: Next.js orchestrates the pipeline. Tensorlake is called per agent step (same runTensorlake() as chat). The runtime stays stateless.
Runner Connections
Runners can connect directly to MCP servers, database integrations, custom tools, web tools, plans, and knowledge — the same resources available to agents. These runner-level connections serve as defaults for all steps in the pipeline.
How merging works
When a step references an agent (agent_id), both runner-level and agent-level configs are merged:
| Resource | Merge strategy |
|---|---|
| MCP servers | Combined; deduplicated by slug (agent wins on conflict) |
| Integrations | Combined; agent overrides runner for same integration ID |
| Tools | Combined; deduplicated by name (agent wins on conflict) |
| Web config | Agent overrides runner when agent has web config enabled |
Steps without an agent_id use runner-level connections exclusively.
Attaching resources
// Attach an MCP server to a runner
await fj.attachRunnerMcp(runner.id, 'mcp-server-uuid');
// Attach a database integration
await fj.attachRunnerIntegration(runner.id, 'integration-uuid');
// Enable web tools
await fj.updateRunnerWebConfig(runner.id, {
enabled: true,
searchEnabled: true,
researchEnabled: true,
});
// Enable plans
await fj.updateRunnerPlanConfig(runner.id, { enabled: true });
Runner-scoped knowledge
Upload knowledge docs scoped to a runner. These are automatically retrieved via similarity search during agent step execution.
curl -X POST https://api.flapjack.dev/api/knowledge/upload \
-H "Authorization: Bearer fj_live_..." \
-F "file=@product-catalog.txt" \
-F "scopeType=runner" \
-F "scopeId=RUNNER_ID" \
-F "title=Product Catalog"
Runner-scoped knowledge is available to all steps in that runner, alongside org-scoped knowledge.
Connection tables
| Table | Purpose |
|---|---|
runner_mcps | MCP server attachments (many-to-many) |
runner_integrations | Database integration attachments |
runner_tools | Custom tool attachments |
runner_web_config | Web tools config (1:1) |
runner_plan_config | Plan config (1:1) |
Step Types
agent — LLM execution via Tensorlake
- Can reference an existing Agent (
agent_id) or define inline config - When
agent_idis set: loads the agent's preamble, model, tools, MCPs, integrations preamble_overrideprepends to agent preamble (or IS the preamble for inline steps)model_overrideoverrides the modeloutput_schemainstructs the LLM to return structured JSON
webhook — HTTP POST to external service
- POSTs step input as JSON to
webhook_url - Expects JSON response as step output
condition — Flow control
- Evaluates
condition_expr(JSONPath) against run context - If falsy: remaining steps are skipped
computer — Sandbox execution (Phase 2)
- Executes code in a Tensorlake sandbox (ephemeral) or heyvm (persistent)
- Same infrastructure as the Agent Computer feature —
SandboxClientfor Tensorlake, heyvm API for persistent - Useful for deterministic pipeline stages (image processing, data transforms) that don't need LLM reasoning
Input Mapping (JSONPath)
Steps receive input from the run context via input_mapping. The context accumulates as steps complete:
{
"input": { "url": "https://example.com" },
"steps": {
"discovery": { "output": { "products": [...] }, "status": "completed" },
"extraction": { "output": { "extracted": [...] }, "status": "completed" }
}
}
Mapping example:
{
"products": "$.steps.discovery.output.products",
"url": "$.input.url"
}
Triggers
Manual / API
curl -X POST /api/runners/{id}/runs \
-H "Authorization: Bearer fj_live_..." \
-d '{"input": {"url": "https://example.com"}}'
Cron
A Vercel Cron job hits POST /api/cron/runners every minute. It evaluates cron expressions against last_fired_at.
Trigger config:
{ "kind": "cron", "cronExpression": "0 9 * * 1", "cronTimezone": "America/New_York" }
Webhook
Each webhook trigger gets a unique token. The inbound URL is public:
POST /api/runners/webhooks/{token}
Poll
Poll triggers watch external data sources via MCP tools and fire runs when new events are detected. The system tracks a cursor to avoid re-processing.
Trigger config:
{
"kind": "poll",
"pollConfig": {
"provider": "github",
"mcpServerId": "mcp-server-uuid",
"template": "github_new_prs",
"params": { "repo": "owner/repo" },
"intervalMinutes": 5
}
}
| Field | Description |
|---|---|
provider | Data source key (e.g. github) |
mcpServerId | MCP server to query |
template | Poll template name (defines which MCP tools to call) |
params | Template-specific parameters |
intervalMinutes | How often to check (default: 5) |
The poll_cursor is tracked automatically per trigger and persisted between sweeps so only genuinely new events fire runs.
Bulk Import
curl -X POST /api/runners/{id}/runs/bulk \
-H "Authorization: Bearer fj_live_..." \
-d '{"items": [{"input": {"url": "..."}}, {"input": {"url": "..."}}]}'
Action Buttons (Button Trigger)
- Hosted HTML button:
GET /api/runners/{id}/button - Embeddable script:
<script src="/api/runners/{id}/embed.js"></script>
SDK
import { FlapjackClient } from '@flapjack/sdk';
const fj = new FlapjackClient({ apiKey: 'fj_live_...' });
// Create a runner
const runner = await fj.createRunner({
name: 'Product Discovery',
inputSchema: { type: 'object', properties: { url: { type: 'string' } } },
});
// Add steps
await fj.addRunnerStep(runner.id, {
name: 'discovery',
kind: 'agent',
orderIndex: 0,
agentId: 'agent-uuid',
inputMapping: { source: '$.input.url' },
outputSchema: { type: 'object', properties: { products: { type: 'array' } } },
});
await fj.addRunnerStep(runner.id, {
name: 'notify',
kind: 'webhook',
orderIndex: 1,
webhookUrl: 'https://hooks.slack.com/...',
inputMapping: { count: '$.steps.discovery.output.products.length' },
});
// Add a cron trigger
await fj.addRunnerTrigger(runner.id, {
kind: 'cron',
cronExpression: '0 9 * * 1',
});
// Trigger manually
const run = await fj.triggerRun(runner.id, { input: { url: 'https://example.com' } });
// Poll for completion
const result = await fj.getRun(runner.id, run.id);
console.log(result.status, result.output);
// --- Runner Connections ---
// Attach MCP servers directly to the runner
await fj.attachRunnerMcp(runner.id, 'mcp-server-uuid');
// Attach a database integration
await fj.attachRunnerIntegration(runner.id, 'integration-uuid');
// Enable web tools for the runner
await fj.updateRunnerWebConfig(runner.id, { enabled: true });
// Enable plans for the runner
await fj.updateRunnerPlanConfig(runner.id, { enabled: true });
// List attached MCPs
const mcps = await fj.listRunnerMcps(runner.id);
MCP Tools
All runner operations are available as MCP tools:
- CRUD:
flapjack_list_runners,flapjack_create_runner,flapjack_get_runner,flapjack_update_runner,flapjack_delete_runner - Steps:
flapjack_add_runner_step,flapjack_update_runner_step,flapjack_remove_runner_step - Triggers:
flapjack_add_runner_trigger,flapjack_update_runner_trigger,flapjack_remove_runner_trigger - Runs:
flapjack_trigger_run,flapjack_trigger_run_bulk,flapjack_list_runs,flapjack_get_run,flapjack_cancel_run - Connections:
flapjack_list_runner_mcps,flapjack_attach_runner_mcp,flapjack_detach_runner_mcp,flapjack_list_runner_integrations,flapjack_attach_runner_integration,flapjack_detach_runner_integration - Config:
flapjack_get_runner_web_config,flapjack_update_runner_web_config,flapjack_get_runner_plan_config,flapjack_update_runner_plan_config
Database Tables
| Table | Purpose |
|---|---|
runners | Pipeline definitions |
runner_steps | Ordered stages in a pipeline |
runner_triggers | Trigger configurations |
runner_runs | Execution history |
runner_run_steps | Per-step execution log |
runner_mcps | MCP server attachments |
runner_integrations | Database integration attachments |
runner_tools | Custom tool attachments |
runner_web_config | Web tools config (1:1) |
runner_plan_config | Plan config (1:1) |
runner_budget_config | Per-runner budget enforcement config |
runner_skills | Skill attachments |
All tables use RLS with is_org_member() policies.
Idempotency
Runs support an idempotency_key. A unique index on (runner_id, idempotency_key) prevents duplicate runs:
await fj.triggerRun(runner.id, {
input: { url: 'https://example.com/product-1' },
idempotencyKey: 'https://example.com/product-1', // same key = same run
});
Budget Controls
Runner runs can be budget-capped to prevent runaway costs. Configure a default budget per runner, and each run inherits it.
Runner-Level Budget Config
curl -X PUT /api/runners/{id}/budget \
-H "Authorization: Bearer fj_live_..." \
-d '{"enabled": true, "budgetUsd": 5.00, "onExceed": "pause"}'
| Field | Type | Description |
|---|---|---|
enabled | boolean | Whether budget enforcement is active |
budgetUsd | number | null | Max USD per run (null = unlimited) |
onExceed | 'pause' | 'fail' | What happens when budget is exceeded |
warningThreshold1 | number | First warning at this fraction (default: 0.80) |
warningThreshold2 | number | Second warning at this fraction (default: 0.95) |
Per-Run Budget
Each RunnerRun has:
budget_usd— inherited from runner config at creation timebudget_on_exceed—'pause'or'fail'budget_paused_at— timestamp when the run was paused
When a run exceeds its budget:
fail: The run is markedfailedimmediatelypause: The run enterspaused_budgetstatus and can be resumed
Resuming a Paused Run
curl -X POST /api/runners/{id}/runs/{runId}/resume \
-H "Authorization: Bearer fj_live_..." \
-d '{"additionalBudgetUsd": 2.00}'
The run resumes from its current_step. Optionally provide additionalBudgetUsd to increase the per-run budget before resuming.
Cost Analytics
Runner cost data is recorded per LLM call and attributed by model, enabling per-model cost breakdowns.
Per-Runner Analytics
curl https://api.flapjack.dev/api/runners/{id}/analytics?period=30d \
-H "Authorization: Bearer fj_live_..."
Response (excerpt — also includes runner, period, summary):
{
"cost_by_model": [
{ "model": "claude-sonnet-4-6", "cost_usd": 12.50 },
{ "model": "gpt-5.4", "cost_usd": 8.25 }
],
"daily": [
{ "day": "2026-05-10", "total_runs": 5, "completed": 4, "failed": 1, "cost_usd": 2.10 }
]
}
Models are sorted by cost descending.
Org-Level Analytics
curl https://api.flapjack.dev/api/analytics?period=30d \
-H "Authorization: Bearer fj_live_..."
Response includes by_model (excerpt — full response also contains period, summary, daily, by_agent, top_tools):
{
"by_model": [
{ "model": "claude-sonnet-4-6", "unique_users": 5, "total_turns": 42, "estimated_cost_usd": 125.50 }
]
}
SDK
const analytics = await fj.getRunnerAnalytics(runner.id, { period: '30d' });
console.log(analytics.cost_by_model);
Self-Improvement (Phase 3)
The runners.learned_instructions field accumulates lessons from previous runs. These are prepended to every agent step's preamble, allowing the pipeline to improve over time.
Key Files
| File | Purpose |
|---|---|
supabase/migrations/026_runners.sql | Core runner database schema |
supabase/migrations/028_runner_connections.sql | Runner connection tables (MCPs, integrations, tools, web, plan, knowledge) |
lib/agent-config.ts | Shared agent config resolution |
lib/runner-config.ts | Runner config resolution (MCPs, integrations, tools, web, plan) |
lib/runner-engine.ts | Pipeline execution orchestrator (merges runner + agent configs) |
app/api/runners/ | API routes (CRUD, steps, triggers, runs, connections) |
sdk/src/types.ts | SDK types |
sdk/src/client.ts | SDK client methods |
mcp/src/index.ts | MCP tools |