The Difference
Four handlers that drift apart — or one function that doesn't.
// Same logic, copied per protocol
app.get('/cards/:id', auth, validate, async (req, res) => {
const card = await db.getCard(req.params.id)
res.json(card)
})
ws.on('getCard', auth, validate, async (msg, socket) => {
const card = await db.getCard(msg.cardId) // <- again
socket.send(JSON.stringify(card))
})
// + queue, cron, CLI, RPC, SSE... each drifts.
// --- Workflow? Add Inngest / Temporal ----
inngest.createFunction({ id: 'onboarding' }, ...,
async ({ step }) => {
await step.run('create-profile', () => createProfile(id))
await step.sleep('wait', '5m')
await step.run('send-welcome', () => sendWelcome(id))
})
// New SDK, new schema, new deploy pipeline.
// --- AI agent? Add Vercel AI / LangChain -
const tools = { getCard: tool({
parameters: z.object({ cardId: z.string() }),
execute: async ({ cardId }) => db.getCard(cardId),
})} // Auth? Permissions? You're on your own.
// Three frameworks. Three auth layers. One backend.
// With Pikku — write it once
const getCard = pikkuFunc({
func: async ({ db, audit }, { cardId }) => {
const card = await db.getCard(cardId)
await audit.log('getCard', { cardId })
return card
},
permissions: { user: isAuthenticated }
})
// Wire it to anything — same auth, same logic
wireHTTP({ method: 'get', route: '/cards/:cardId', func: getCard })
wireChannel({ name: 'cards', onMessage: { getCard } })
wireQueueWorker({ queue: 'fetch-card', func: getCard })
wireCLI({ program: 'cards', commands: { get: getCard } })
// Workflows just reference your functions
const onboarding = pikkuWorkflowFunc(
async ({}, { userId }, { workflow }) => {
await workflow.do('Create profile', 'createProfile', { userId })
await workflow.sleep('Wait 5 min', '5m')
await workflow.do('Send welcome', 'sendWelcome', { userId })
}
)
// AI agents too — same functions, same auth
const support = pikkuAgent({
tools: [getCard, getOrders, createTicket],
model: 'claude-sonnet-4-5'
})
// Auth, permissions, and validation carry over. Done.
Same function. Any transport.
The function on the left works with every protocol on the right. Same auth, same validation, zero rewrites.
Write once
const getCard = pikkuFunc({
title: 'Get Card',
description: 'Retrieve a card by ID',
func: async ({ db, audit }, { cardId }) => {
const card = await db.getCard(cardId)
await audit.log('getCard', { cardId })
return card
},
permissions: { user: isAuthenticated }
})
Pick a protocol


Wiring code
wireHTTP({
method: 'get',
route: '/cards/:cardId',
func: getCard
})
Your functions are already agent tools
No adapters. No schema writing. No separate auth layer. Pass your existing Pikku functions directly — the agent gets your full backend.
// These already exist in your backend — no changes needed
import { getCustomer, getOrders, createTicket } from './functions'
export const supportAgent = pikkuAgent({
name: 'support',
instructions: `You are a helpful support agent.
Look up the customer's account and recent orders.`,
tools: [getCustomer, getOrders, createTicket],
model: 'claude-sonnet-4-5'
})
// Wire it just like any HTTP endpoint
wireHTTP({
method: 'post',
route: '/api/chat',
func: supportAgent
})
Zero glue code
Pass any Pikku function as a tool — the agent inherits its type signature, description, and input schema automatically. No adapters, no wrappers, no manual schema definitions.
Auth follows the agent
Agents inherit the caller's session, permissions, and middleware. The same rules that protect your HTTP endpoints automatically protect every tool the agent can call.
Any LLM, same interface
Bring OpenAI, Anthropic, or any provider. Pikku handles tool calling, streaming, and context — you just swap the model name.
Multi-step processes that
survive anything
Write sequential logic like normal code. Pikku handles persistence, retries, and resumption — even across server restarts.
Deterministic replay
Completed steps are cached and never re-executed. A workflow that fails on step 4 resumes from step 4 — not from the beginning.
Sleep for hours, days, or weeks
workflow.sleep('5min') suspends execution without holding a server connection. Perfect for trial expirations, reminders, and follow-ups.
Survives restarts
State is persisted to PostgreSQL or Redis between steps. Deploy a new version mid-workflow and execution continues from where it left off.
export const onboardingWorkflow = pikkuWorkflowFunc(
async ({ workflow }, { email, userId }) => {
// Each step is persisted — safe to retry
const user = await workflow.do(
'Create user profile',
'createUserProfile',
{ email, userId }
)
await workflow.do(
'Add to CRM',
async () => crm.createUser(user)
)
// Suspend for 5 minutes — no server held
await workflow.sleep('Wait before welcome email', '5min')
await workflow.do(
'Send welcome email',
'sendEmail',
{ to: email, template: 'welcome' }
)
return { success: true }
}
)
Install a backend feature
in one line
Stripe billing. SendGrid emails. One wireAddon() call each. Install, configure secrets, call via namespaced RPC — fully typed.
// One line per addon
wireAddon({
name: 'stripe',
package: '@pikku/addon-stripe'
})
wireAddon({
name: 'email',
package: '@pikku/addon-sendgrid',
secretOverrides: {
SENDGRID_API_KEY: 'MY_EMAIL_KEY'
}
})
// Call addon functions via namespaced RPC
const checkout = pikkuFunc({
func: async ({}, { plan }, { rpc }) => {
const session = await rpc.invoke(
'stripe:checkoutCreate',
{ plan, currency: 'usd' }
)
await rpc.invoke(
'email:mailSend',
{ to: session.email, template: 'receipt' }
)
return { url: session.url }
}
})
Drop-in, not bolt-on
Install a package, add one wireAddon() call, and its functions appear as namespaced RPC calls. No glue code, no adapters, no boilerplate.
Fully typed across boundaries
The CLI generates TypeScript definitions for every addon function. rpc.invoke('stripe:checkoutCreate', ...) is autocompleted with the exact input and output types.
Secrets you control
Addons declare what secrets they need. You map them to your own infrastructure with secretOverrides — no env vars leaking across packages.
Shared infrastructure
Addons reuse your existing logger, database, and services — no duplicate connections. Each addon gets its own namespace, so nothing collides.
One handler for every
messaging platform
WhatsApp, Slack, Telegram, WebChat — write one function. The adapter normalizes every platform into the same message format. Three transport types cover every integration pattern.
// Webhook — platform POSTs to you
wireGateway({
name: 'whatsapp',
type: 'webhook',
route: '/webhooks/whatsapp',
adapter: whatsAppAdapter,
func: handleMessage,
})
// WebSocket — real-time web chat
wireGateway({
name: 'webchat',
type: 'websocket',
route: '/chat',
adapter: webChatAdapter,
func: handleMessage,
})
// Listener — persistent connection (Baileys, Signal)
wireGateway({
name: 'signal',
type: 'listener',
adapter: signalAdapter,
func: handleMessage,
})
// One handler for all platforms
const handleMessage = pikkuFunc({
func: async ({ database, logger }, { senderId, text }) => {
logger.info(`${senderId}: ${text}`)
await database.saveMessage(senderId, text)
// Return value is auto-sent via the adapter
return { text: `Got it! You said: ${text}` }
}
})
Webhook with auto-verification
Registers POST and GET routes automatically. WhatsApp challenges, Slack url_verification, Telegram tokens — handled by the adapter, invisible to your code.
Normalized messages
Every platform delivers the same GatewayInboundMessage — senderId, text, attachments, metadata. Your handler never knows which platform sent the message.
Three transport types
Webhook for cloud APIs (WhatsApp, Slack, Telegram). WebSocket for browser chat widgets. Listener for persistent connections (Baileys, Signal CLI, Matrix).
Same middleware, same auth
Rate limiting, logging, permissions — your existing middleware works on gateways. wire.gateway gives you platform, senderId, and send() in every handler.
Change your runtime.
Keep your functions.
Same code runs on Express, Fastify, AWS Lambda, Cloudflare Workers, Next.js, and more. Switching runtimes never requires touching your functions.
Plus any custom runtime via the adapter interface. Build your own →
Built For Production
Production-grade out of the box
Auth, validation, type-safe clients, middleware — all built in. No bolting on third-party packages for every new protocol.
MIT licensed. Standard TypeScript. No VC-backed lock-in.
Type-Safe Clients
Auto-generated HTTP, WebSocket, and RPC clients with full IntelliSense.
Auth & Permissions
Cookie, bearer, API key auth with fine-grained permissions — built in.
Services
Singleton and per-request dependency injection, type-safe and testable.
Middleware
Before/after hooks for logging, metrics, tracing — work across all protocols.
Schema Validation
Runtime validation against TypeScript input schemas. Supports Zod.
Zero Lock-In
Standard TypeScript, tiny runtime, MIT licensed. Bring your own everything.
Every function, every wire,
every secret — one screen
Browse functions, run agents, manage secrets, and trigger workflows — without writing tooling code.

Built for the problems developers actually have
From the teams who switched
"So many places in my code base have like three entry points: CLI, public (sometimes protected) HTTP API and internally from within the API. Would be so nice having everything just an invoke away. With Nest it's a pain because you basically have to start the whole API up just to run CLI command."
Alex Harley
Co-founder @ Superbridge
"Ever been annoyed at having to write your code different in a Lambda than in an express handler? Pikku fixes that."
Christoph Sitter
CTO @ spot
Stop copying functions across protocols.
Write it once. Pikku wires it everywhere.
5-minute setup · MIT Licensed · Open Source
