Universally Triggered Agent Harness - An OpenClaw-like Inngest-powered personal agent
Universally Triggered Agent Harness
A durable AI agent built with Inngest and pi-ai. No framework. Just a think/act/observe loop β Inngest provides durability, retries, and observability, while pi-ai provides a unified LLM interface across providers.
Simple TypeScript that gives you:
connect(), no server neededChannel (e.g. Telegram) β Inngest Cloud (webhook + transform) β WebSocket β Local Worker β LLM (Anthropic/OpenAI/Google) β Reply Event β Channel API
The worker connects to Inngest Cloud via WebSocket. No public endpoint. No ngrok. No VPS. Messages flow through Inngest as events, and the agent processes them locally with full filesystem access.
The core agent handles conversations. But conversations are ephemeral β the agent forgets, the process restarts, the context window rolls over. The sidecar is what makes Utahβs output durable.
A separate process (utah-sidecar) dynamically loads Inngest functions from disk, connects to Inngest Cloud via WebSocket, and runs them independently. The agent can write a new .ts file to the functions directory and the sidecar hot-reloads it automatically β no restart, no deploy, no human intervention.
The key idea: the agent doesnβt just run inside loops β it authors new loops and deploys them to the orchestration engine. Each deployed function is a durable skill that runs on its own schedule, with its own retry logic, completely independent of whether the agent is in a conversation.
βββββββββββββββββββββββ βββββββββββββββββββββββββ
β Core Agent β β Sidecar β
β app: "ai-agent" β β app: "utah-sidecar" β
β β β β
β handleMessage β β workspace/functions/β
β sendReply β β *.ts (dynamic) β
β subAgent β β + heartbeat (auto) β
β etc. β β + file watcher β
ββββββββββ¬βββββββββββββ ββββββββββ¬βββββββββββββββ
β β
β connect() via WebSocket β
ββββββββββββ¬βββββββββββββββββββ
β
ββββββββββΌβββββββββ
β Inngest Cloud β
β events, crons, β
β retries, state β
βββββββββββββββββββ
Both processes connect to Inngest independently. They share nothing except the event bus.
workspace/functions/*.ts, dynamically imports each file, and registers the exported Inngest functionsfs.watch() monitors the directory β on any change, a 2-second debounce fires, the existing WebSocket closes, functions are re-imported with cache-busting, and a new connection opensThe agent can author new Inngest functions β cron jobs, event handlers, multi-step workflows β by writing a .ts file to workspace/functions/. The sidecar deploys them automatically.
Some example functions that the main agent might write to extend itself: morning-triage, daily-meeting-digest, nightly-workspace-commit, weekly-review. You can also create βloopsβ with review functions that use LLMs to review and iterate on functions, for example: inbox-triage-review, cold-email-learner.
Each function is durable β retried on failure, observable in the Inngest dashboard, independently scheduled. Skills compound. The agent builds infrastructure for itself.
Agent skills are markdown reference docs (with name/description frontmatter) that appear in the agentβs system prompt. The agent can create its own skills to persist knowledge across conversations.
This creates a self-referential system:
The agent is ephemeral. Its output is durable.
Sidecar functions talk back to the main agent by sending agent.message.received events:
await step.sendEvent("alert-agent", {
name: "agent.message.received",
data: {
channel: "system",
sessionKey: "system-alerts",
message: "Alert: something needs attention",
},
});
This means a cron job can monitor something, detect a problem, and start a conversation with the agent β which can then use its tools to investigate and respond. The loops feed each other.
git clone https://github.com/inngest/utah
cd utah
npm install # or pnpm
cp .env.example .env
Edit .env with your keys:
ANTHROPIC_API_KEY=sk-ant-...
INNGEST_EVENT_KEY=...
INNGEST_SIGNING_KEY=signkey-prod-...
Then add the environment variables for your channel(s) β see setup guides below.
Start the worker:
# Production mode (connects to Inngest Cloud via WebSocket)
npm start
# Development mode (uses local Inngest dev server)
npx inngest-cli@latest dev &
npm run dev
On startup, the worker automatically sets up webhooks and transforms for each configured channel.
The agent supports multiple messaging channels. Each channel has its own setup guide:
src/
βββ worker.ts # Entry point β connect() or serve()
βββ client.ts # Inngest client
βββ config.ts # Configuration from env vars
βββ agent-loop.ts # Core think β act β observe cycle
βββ setup.ts # Channel setup orchestration
βββ lib/
β βββ llm.ts # pi-ai wrapper (multi-provider: Anthropic, OpenAI, Google)
β βββ tools.ts # Tool definitions (TypeBox schemas) + execution
β βββ context.ts # System prompt builder with workspace file injection
β βββ session.ts # JSONL session persistence
β βββ memory.ts # File-based memory system (daily logs + distillation)
β βββ compaction.ts # LLM-powered conversation summarization
βββ functions/
β βββ message.ts # Main agent function (singleton + cancelOn)
β βββ send-reply.ts # Channel-agnostic reply dispatch
β βββ acknowledge-message.ts # Message acknowledgment (typing indicator, etc.)
β βββ heartbeat.ts # Cron-based memory maintenance
β βββ failure-handler.ts # Global error handler with notifications
βββ channels/
βββ types.ts # ChannelHandler interface
βββ index.ts # Channel registry
βββ setup-helpers.ts # Inngest REST API helpers for webhook setup
βββ <channel-name>/ # A channel implementation (see README for setup)
βββ handler.ts # ChannelHandler implementation
βββ api.ts # API client
βββ setup.ts # Webhook setup automation
βββ transform.ts # Webhook transform
βββ format.ts # Formatting for channel messages
workspace/ # Agent workspace (persisted across runs)
βββ SOUL.md # Agent personality and behavioral guidelines
βββ USER.md # User information
βββ MEMORY.md # Long-term memory (agent-writable)
βββ memory/ # Daily logs (YYYY-MM-DD.md, auto-managed)
βββ sessions/ # JSONL conversation files (gitignored)
The core is a while loop where each iteration is an Inngest step:
step.run("think") calls the LLM via pi-aiβs complete()step.run("tool-read")Inngest auto-indexes duplicate step IDs in loops (think:0, think:1, etc.), so you donβt need to track iteration numbers in step names.
One incoming message triggers multiple independent functions:
| Function | Purpose | Config |
|---|---|---|
agent-handle-message |
Run the agent loop | Singleton per chat, cancel on new message |
acknowledge-message |
Show βtypingβ¦β immediately | No retries (best effort) |
send-reply |
Format and send the response | 3 retries, channel dispatch |
agent-heartbeat |
Distill daily logs into long-term memory | Cron (every 30 min) |
global-failure-handler |
Catch errors, notify user | Triggered by inngest/function.failed |
The agent reads markdown files from the workspace directory and injects them into the system prompt:
| File | Purpose |
|---|---|
SOUL.md |
Agent personality, behavioral guidelines, tone, boundaries |
USER.md |
Info about the user (name, timezone, preferences) |
MEMORY.md |
Curated long-term memory (agent-writable) |
Edit these files to customize your agentβs personality and knowledge. The agent can also update MEMORY.md using the write tool to remember things across conversations.
The agent has a two-tier memory system:
workspace/memory/YYYY-MM-DD.md) β append-only notes written via the remember tool during conversationsworkspace/MEMORY.md) β curated summary distilled from daily logs by the heartbeat functionThe agent-heartbeat function runs on a cron schedule (default: every 30 minutes). It checks if daily logs have accumulated enough content, then uses the LLM to distill them into MEMORY.md. Old daily logs are pruned after a configurable retention period (default: 30 days).
Long conversations get summarized automatically so the agent doesnβt lose context or hit token limits:
Compaction runs as an Inngest step (step.run("compact")), so itβs durable and retryable.
Long tool results bloat the conversation context and cause the LLM to lose focus. The agent uses two-tier pruning:
The agent is channel-agnostic. Each channel implements a ChannelHandler interface (src/channels/types.ts) with methods for sending replies, acknowledging messages, and setup. Each channel directory follows the same structure:
src/channels/<name>/
βββ handler.ts # ChannelHandler implementation (sendReply, acknowledge)
βββ api.ts # API client for the channel's platform
βββ setup.ts # Webhook setup automation
βββ transform.ts # Plain JS transform for Inngest webhook
βββ format.ts # Markdown β channel-specific format conversion
To add Discord, WhatsApp, or any other channel:
src/channels/ following the structure aboveChannelHandler interface in handler.tsagent.message.receivedsrc/channels/index.tsThe agent loop, reply dispatch, and acknowledgment functions are all channel-agnostic β no changes needed outside src/channels/.
connect() β WebSocket-based workerThis project uses pi-ai (@mariozechner/pi-ai) by Mario Zechner for its unified LLM interface and @mariozechner/pi-coding-agent for itβs. standard tools. pi-ai provides a single complete() function that works across Anthropic, OpenAI, Google, and other providers β making it easy to swap models without changing any agent code. Itβs a great library.
Apache-2.0