Skip to main content
Core Concept

Your logic.
Nothing else.

A Pikku function receives (services, data, wire) and works across every protocol — HTTP, WebSocket, queue, CLI, MCP, and more.

The Function Signature

Three parameters. That's it.

Every Pikku function receives the same three arguments — no matter which protocol triggers it.

Services

Your toolbox — database, logger, JWT, email, anything you register. Destructure only what you need.

{ db, logger, jwt }

Data

Typed, validated input — normalized from any protocol. Path params, body, query, message payload — all merged.

{ bookId, title }

Wire

Session and optional protocol helpers. session works everywhere — protocol-specific fields like http or rpc only appear when relevant, and you never have to use them.

{ session, setSession }
getBook.func.tsfunc.ts
const getBook = pikkuFunc({
title: 'Get Book',
func: async (
{ db, logger }, // services — your toolbox
{ bookId }, // data — typed input
{ session } // wire — protocol context
) => {
logger.info(`Fetching book ${bookId}`)
const book = await db.getBook(bookId)
return { book, reader: session.userId }
}
})

Services

Your toolbox, injected

Services are dependency-injected into every function. Register once, destructure anywhere.

Singleton services

Created once at startup, shared across all requests. Database connections, loggers, third-party clients.

Wire services

Created fresh per request. Session loaders, audit contexts, per-request caches. Lazily instantiated only when destructured.

Destructure what you need

Only pull the services your function actually uses. Keeps code clean and makes dependencies explicit.

services.tsstartup
import { PikkuServiceMap } from '.pikku/pikku-types.gen.js'

// Singleton services — created once at startup
const singletonServices: PikkuServiceMap = {
db: new DatabaseClient(DATABASE_URL),
logger: createLogger({ level: 'info' }),
jwt: new JWTService(JWT_SECRET),
email: new EmailClient(SMTP_CONFIG),
}
wire-services.tsper-request
// Wire services — created fresh per request
const wireServices = {
// Each request gets its own session loader
session: (services, wire) => loadSession(wire),
// Per-request audit context
audit: (services, wire) => new AuditLog(wire.requestId),
}

Contracts & Versioning

Never accidentally break a client

Pikku hashes every function's input and output schema. Change a contract without bumping the version and the build fails — before it reaches production.

Contracts are tracked automatically

Every function's name + input schema + output schema = a contract hash. The CLI stores these in a versions.json manifest.

Breaking changes fail the build

If you change a published schema without bumping the version, pikku versions-check fails. Add it to CI and breaking changes never ship by accident.

Version bumps are explicit

Set version: 2 on the function, run pikku versions-update, commit. Old and new versions coexist — no migration needed.

CI Pipelinefailed
# CI catches breaking changes before deploy
$ npx pikku versions-check

✗ getBook — contract changed without version bump
  Input schema hash:  a1b2c3d4 → f9e8d7c6
  Output schema hash: i9j0k1l2 → z5y4x3w2

  Run: npx pikku versions-update
  after bumping to version 2
getBook.func.tsversion: 1 → 2
// v1 — kept around for old clients
const getBookV1 = pikkuFunc({
title: 'Get Book',
version: 1,
input: z.object({ bookId: z.string() }),
output: z.object({ title: z.string() }),
func: async ({ db }, { bookId }) => {
return await db.getBook(bookId)
}
})

// v2 — the latest version, called by default
const getBook = pikkuFunc({
title: 'Get Book',
version: 2,
input: z.object({ bookId: z.string(), format: z.enum(['full', 'summary']) }),
output: z.object({ title: z.string(), author: z.string(), isbn: z.string() }),
func: async ({ db }, { bookId, format }) => {
return await db.getBook(bookId, format)
}
})
Clients call the latest version by default — old versions stay available
Works across all wires: HTTP, RPC, WebSocket, MCP
Schema hashes are deterministic and diffable in Git

Session & Auth

One session API, every transport

Whether the request arrives over HTTP, WebSocket, or CLI — your function reads and writes the session the same way.

1

Middleware loads

Session populated from cookie, token, or connection state

2

Function receives

Access session via the wire parameter — read userId, role, etc.

3

Function modifies

Call setSession() or clearSession() to update

4

Middleware persists

Changes saved back to the transport — cookie, store, etc.

login.func.tsfunc.ts
const login = pikkuFunc({
title: 'Login',
func: async (
{ jwt, db },
{ email, password },
{ setSession }
) => {
const user = await db.verifyCredentials(email, password)
const token = jwt.sign({ userId: user.id, role: user.role })

// Works the same whether it's an HTTP cookie,
// WebSocket connection, or CLI token
setSession({ userId: user.id, role: user.role })

return { token }
}
})
getMe.func.tsfunc.ts
const getMe = pikkuFunc({
title: 'Get Current User',
func: async ({ db }, {}, { session }) => {
// session is loaded by middleware before
// your function runs — same API everywhere
return await db.getUser(session.userId)
},
permissions: { user: isAuthenticated }
})

Write Once, Wire Everywhere

One function. Every protocol.

The same function handles HTTP requests, WebSocket messages, queue jobs, CLI commands, and MCP tools — zero duplication.

HTTP
WebSocket
Queue
CLI
MCP

Same Function

getBook(services, data, wire)
getBook.func.tsfunc.ts
// Define once
const getBook = pikkuFunc({
title: 'Get Book',
func: async ({ db }, { bookId }, { session }) => {
return await db.getBook(bookId)
},
permissions: { user: isAuthenticated }
})
wirings.tswiring.ts
// Wire to everything
wireHTTP({ method: 'get', route: '/books/:bookId', func: getBook })
wireWebSocket({ channel: 'books', func: getBook })
wireQueue({ queue: 'book-requests', func: getBook })
wireCLI({ command: 'get-book', func: getBook })
wireMCP({ tool: 'get_book', func: getBook })

Zero duplication

Business logic lives in one place. Each wire adapts the protocol to your function.

Same permissions

Auth and permission checks apply regardless of which wire triggers the function.

Same types

Input and output types are shared. Change once, every wire gets the update.

Start building in 5 minutes

One command to scaffold a project. Your first function will work across every protocol from day one.

$ npm create pikku@latest

MIT Licensed  ·  Works with Express, Fastify, Lambda & Cloudflare