tickle-stick
v0.4.2
Published
Opinionated cost-hierarchy pipeline for agentic workflows. Triage cheaply before committing expensive intelligence.
Maintainers
Readme
Tickle-Stick
Cost-hierarchy pipeline for agentic workflows.
Named after the diving tool used to gently probe before committing.

The Problem
You're running your agents wrong.
Every scheduled task — email check, calendar sync, dependabot alerts — triggers a full agent loop at ~$0.15 per invocation. That's $216/month just for email checks. Most of those tasks don't need intelligence.
The Solution: Stage-Based Pipelines
Run cheap scripts first. Classify with a cheap model. Only invoke expensive reasoning when items actually need it. Apply side effects at each step.
Scheduled Task (cron trigger from host)
│
┌────▼─────┐
│ Script │ Shell/Python — fetch raw data
│ FREE │ Output: WorkItem[] (JSON stdout)
│ <50ms │ Empty = pipeline stops here ($0)
└────┬──────┘
│ items found
┌────▼─────┐
│ Model │ Cheap model — classify items
│ (cheap) │ routine / urgent / needs-reasoning
│ ~$0.001 │ Post-hook: apply labels immediately
└────┬──────┘
│ filtered items
┌────▼─────┐
│ Model │ Expensive model — host callback
│(expensive)│ Reason, synthesize, draft responses
│ ~$0.05+ │ Post-hook: create drafts, apply labels
└────┬──────┘
│
┌────▼─────┐
│ Callback │ Host-provided function
│ FREE │ Deliver, label, escalate — host decides
└───────────┘Cost Comparison
| Scenario | Without Tickle-Stick | With Tickle-Stick | Savings | | ------------------------------ | -------------------- | ----------------- | ------- | | Daily email check (60% empty) | $15.00/day | $0.60/day | 96% | | 5 cron tasks/day (80% no data) | $3.75/day | $0.15/day | 96% | | Weekly retro + daily briefing | $1.80/week | $0.30/week | 83% |
Requirements
- Node.js ≥ 20 (declared in
engines). - ESM-only. Consumers must import, not
require(). - TypeScript consumers must use
"moduleResolution": "bundler"or"node16"(or newer) intsconfig.json. The library ships.js-suffixed import specifiers per Node16 convention.
Quick Start
Install:
npm install tickle-stickThis end-to-end example runs with no external services — it uses an
inline fake triage provider and an echo shell command. Paste into
quickstart.mjs, then node quickstart.mjs.
import { Pipeline } from "tickle-stick";
// A fake "cheap model" — classifies everything as needs-reasoning.
// Replace with HttpTriageProvider (see below) for real OpenAI/Anthropic.
const triageProvider = {
name: "fake-triage",
async classify(text) {
return {
classification: "needs-reasoning",
response: `stub for: ${text.slice(0, 40)}`,
confidence: 0.9,
};
},
};
const pipeline = new Pipeline({
name: "demo",
config: {
stages: [
{
name: "gather",
type: "script",
command: "echo",
args: [
'[{"id":"demo-1","source":"chat","type":"message","summary":"Hello world","timestamp":"2026-04-22T10:00:00Z"}]',
],
timeout: 5000,
},
{
name: "classify",
type: "model",
provider: "cheap",
systemPrompt: "Classify the item.",
confidenceThreshold: 0.7,
},
{
name: "reason",
type: "model",
provider: "expensive",
prompt: "Synthesize a response for: {{items}}",
input: "classified:needs-reasoning",
},
{ name: "deliver", type: "callback" },
],
},
triageProvider,
stageCallbacks: {
reason: async (items) => `reasoned ${items.length} item(s)`,
deliver: async (items) => {
console.log("delivered:", items.map((i) => i.summary).join(", "));
return "";
},
},
});
const result = await pipeline.run();
console.log(
`items=${result.totalItems} cost=$${result.costEstimate.toFixed(4)}`,
);Expected output (with telemetry logs elided):
delivered: Hello world
items=1 cost=$0.0010That's the full loop: script stage produces one item, the fake triage
provider classifies it, the reason stage runs the callback, and the
deliver callback prints the summary.
YAML Configuration
For production use, move the stage definitions into a YAML file and load
them with loadConfig():
# tickle-stick.yaml
tickleStick:
pipelines:
email-check:
stages:
- name: gather
type: script
command: "python3"
args: ["scripts/check-email.py"]
timeout: 30000
- name: classify
type: model
provider: cheap
systemPrompt: |
Classify this item as JSON:
{"classification": "routine"|"urgent"|"needs-reasoning",
"response": "one-line summary", "confidence": 0.0-1.0}
confidenceThreshold: 0.7
postHook:
command: "python3"
args: ["scripts/apply-labels.py"]
- name: reason
type: model
provider: expensive
prompt: |
Here are items that need reasoning:
{{items}}
Synthesize a response.
input: "classified:needs-reasoning,classified:urgent"
- name: deliver
type: callbackimport { Pipeline, loadConfig } from "tickle-stick";
const config = loadConfig(); // reads ./tickle-stick.yaml or ./config/tickle-stick.yaml
const pipelineConfig = config.tickleStick.pipelines["email-check"];
const pipeline = new Pipeline({
name: "email-check",
config: pipelineConfig,
triageProvider,
stageCallbacks: {
reason: async (items, prompt) => callYourReasoningModel(prompt),
deliver: async (items) => {
await sendToChannel(items.map((i) => i.summary).join("\n"));
return "";
},
},
});
const result = await pipeline.run();Script Stages
Script stages are shell commands that output JSON WorkItem[] to stdout:
#!/usr/bin/env python3
import json, sys
items = [
{
"id": "email-001",
"source": "gmail",
"type": "email",
"summary": "Meeting tomorrow at 10am",
"body": "Full email body here...",
"timestamp": "2026-03-25T10:00:00Z"
}
]
json.dump(items, sys.stdout)If the script outputs [] or fails, the pipeline stops at the first stage with $0 cost.
Provider Injection
Tickle-stick does not manage model providers. The host passes in a
TriageProvider implementation for cheap model stages:
import type { TriageProvider } from "tickle-stick";
import { parseClassificationResponse } from "tickle-stick";
const myProvider: TriageProvider = {
name: "my-provider",
async classify(text, systemPrompt) {
const raw = await callYourModel(text, systemPrompt);
return parseClassificationResponse(raw);
},
};Or use the built-in HttpTriageProvider for OpenAI/Anthropic APIs:
import { HttpTriageProvider } from "tickle-stick";
const provider = new HttpTriageProvider({
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-4o-mini",
provider: "openai",
});Cheap vs. expensive stages
The two kinds of type: model stage wire to different host-supplied mechanisms:
provider: cheap— uses thetriageProvideroption passed toPipeline. The provider returns a structured{classification, response, confidence}triple. Single wiring point for every cheap-model stage.provider: expensive— usesstageCallbacks[stageName]. The callback receives filtered items and the rendered prompt; it returns a string. Keyed by stage name so you can wire multiple expensive stages independently.
The asymmetry is deliberate: cheap stages share a classifier contract; expensive stages are free-form per-stage reasoning calls.
Post-Hooks
Any stage can have a postHook — a script that runs after the stage completes.
The stage output is piped to stdin as JSON. Use post-hooks for side effects:
- name: classify
type: model
provider: cheap
systemPrompt: "..."
postHook:
command: "python3"
args: ["scripts/apply-spam-labels.py"]
timeout: 15000Post-hook errors are logged but don't fail the pipeline.
Input Filters
Control which items a stage sees with the input field:
all— everything from all previous stagesclassified:needs-reasoning— only items classified as needs-reasoningclassified:urgent,classified:needs-reasoning— comma-separated union- (omitted) — all items from previous stages
The classified:* label values must match the classification field
returned by your TriageProvider.
Budget & Alerts
Cap cheap model spend and get notified when thresholds are crossed:
tickleStick:
budget:
maxDailySpend: 1.00
maxWeeklySpend: 5.00
alerts:
- at: "80%"
- at: 0.50
retentionDays: 30When a budget cap is reached, cheap model stages are skipped — all items pass through to downstream stages.
Storage Adapter
Budget tracking requires a storage adapter to persist events:
import type { StorageAdapter } from "tickle-stick";
const storage: StorageAdapter = {
writeEvent(event) {
db.run("INSERT INTO pipeline_events ...", event);
},
getSpendSince(since) {
return db.get("SELECT SUM(cost_estimate) ...", since);
},
prune(before) {
return db.run("DELETE FROM pipeline_events WHERE timestamp < ?", before);
},
};Alert Sink
import type { AlertSink } from "tickle-stick";
const alertSink: AlertSink = (alert) => {
sendToMyChannel(`[Budget] ${alert.message}`);
};Wiring Budget
const pipeline = new Pipeline({
name: "email-check",
config: pipelineConfig,
triageProvider: myProvider,
stageCallbacks: { reason: reasoningCallback },
storage,
alertSink,
budgetConfig: config.tickleStick.budget,
timezone: "America/New_York",
});
// Retention enforcement is automatic — the BudgetManager prunes
// events older than `retentionDays` on the first budget check of a
// process and on each subsequent day rollover. The call below is an
// escape hatch for hosts that want to prune on demand (e.g. tests,
// migrations).
await pipeline.pruneBudgetEvents();Budget Status API
const status = await pipeline.getBudgetStatus();
if (status) {
console.log(`Today: $${status.dailySpend.toFixed(2)}`);
console.log(`This week: $${status.weeklySpend.toFixed(2)}`);
console.log(`Exceeded: ${status.exceeded}`);
}Public-beta notice (0.3.x)
Tickle-stick 0.3.x is a public beta. The core pipeline contract
(Pipeline, TriageProvider, StorageAdapter, YAML schema) is stable,
but a handful of internal-plumbing exports and config edge cases are still
being narrowed before 1.0. See the
1.0 tracking issue
(label moscow:could) for the full list.
License
MIT — see LICENSE.
Built by Kromatic. We help teams build better products through innovation accounting and experimentation.
