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.

// Every Pikku function
async (
{ db, logger, jwt }// services
{ bookId, title }// data
{ session }// wire
) => { ... }
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.

{ session, setSession }
getBook.func.tsfunc.ts
export const getOrderThreeParams = pikkuFunc({
func: async (
{ kysely, logger }, // services — your toolbox
{ orderId }, // data — typed input
{ session }, // wire — protocol context
) => {
logger.info(`Fetching order ${orderId}`);
const order = await kysely
.selectFrom('order')
.selectAll()
.where('orderId', '=', orderId)
.executeTakeFirstOrThrow();
return { order, viewer: 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
export const createSingletonServices = pikkuServices(
async (config, existingServices) => {
const variables =
existingServices?.variables ??
new TypedVariablesService(new LocalVariablesService());
const secrets =
existingServices?.secrets ??
new TypedSecretService(new LocalSecretService(variables));
const logger =
existingServices?.logger ?? new JsonConsoleLogger();
const schema =
existingServices?.schema ?? new CFWorkerSchemaService(logger);
const paymentService =
existingServices?.paymentService ?? new FakePaymentService();

// In CF Workers DATABASE_URL is injected by Fabric.
// In local dev (pikku dev) existingServices.kysely is the node:sqlite instance.
// In tests existingServices.kysely is provided by the test support layer.
let kysely: Kysely<DB> | undefined;
if (existingServices?.kysely) {
kysely = existingServices.kysely as Kysely<DB>;
} else {
const databaseUrl = await variables.get('DATABASE_URL');
if (databaseUrl) {
if (/^postgres(ql)?:\/\//.test(databaseUrl)) {
const [{ PostgresJSDialect }, postgres] =
await Promise.all([
import('kysely-postgres-js'),
import('postgres'),
]);
kysely = new Kysely<DB>({
dialect: new PostgresJSDialect({
postgres: postgres.default(databaseUrl),
}),
plugins: [new CamelCasePlugin()],
});
} else {
kysely = new Kysely<DB>({
dialect: new LibsqlWebDialect({ url: databaseUrl }),
plugins: [new CamelCasePlugin()],
});
}
} else {
throw new Error(
'kysely not provided: set DATABASE_URL or pass kysely via existingServices',
);
}
}

return {
...(existingServices ?? {}),
config,
variables,
secrets,
logger,
schema,
kysely,
paymentService,
audit: existingServices?.audit ?? new NoopAuditService(),
};
},
);
wire-services.tsper-request
import { pikkuWireServices } from '../.pikku/pikku-types.gen.js';

export const createWireServices = pikkuWireServices(
async (_singletonServices, _wire) => {
// Created fresh per HTTP request, queue job, CLI command, or WebSocket lifetime.
// Pikku merges these with singletons so functions see one flat object.
// Add per-request services here — e.g. scoped loggers, transactions, etc.
return {};
},
);
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.pikku.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.

CI Pipelinefailed
$ npx pikku versions check

✗ getItem — 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
export const GetItemOutputV1 = z.object({
itemId: z.string(),
name: z.string(),
priceCents: z.number(),
});

export const getItemV1 = pikkuSessionlessFunc({
expose: true,
version: 1,
input: GetItemInput,
output: GetItemOutputV1,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.select(['itemId', 'name', 'priceCents'])
.where('itemId', '=', itemId)
.executeTakeFirstOrThrow();
return row;
},
});

// v2 — adds stock and imageUrl to the response
export const getItemV2 = pikkuSessionlessFunc({
expose: true,
version: 2,
input: GetItemInput,
output: GetItemOutput,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.innerJoin(
'category',
'category.categoryId',
'item.categoryId',
)
.select([
'item.itemId',
'item.name',
'item.slug',
'item.description',
'item.priceCents',
'item.stock',
'item.imageUrl',
'item.isActive',
'item.createdAt',
'item.updatedAt',
'category.categoryId',
'category.name as categoryName',
'category.slug as categorySlug',
])
.where('item.itemId', '=', itemId)
.executeTakeFirstOrThrow();
return {
...row,
category: {
categoryId: row.categoryId,
name: row.categoryName,
slug: row.categorySlug,
},
};
},
});
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
export const shopLogin = pikkuFunc({
auth: false,
func: async (
{ kysely },
{ email, password }: { email: string; password: string },
{ setSession },
) => {
const user = await kysely
.selectFrom('appUser')
.select(['userId', 'role', 'passwordHash'])
.where('email', '=', email.toLowerCase())
.executeTakeFirst();

if (
!user ||
!(await verifyPassword(password, user.passwordHash ?? ''))
) {
throw new Error('Invalid credentials');
}

setSession?.({ userId: user.userId, role: user.role });
return { userId: user.userId, role: user.role };
},
});
getMe.func.tsfunc.ts
export const getProfile = pikkuFunc({
func: async ({ kysely }, _data, { session }) => {
return kysely
.selectFrom('appUser')
.select(['userId', 'name', 'email', 'role'])
.where('userId', '=', session.userId)
.executeTakeFirstOrThrow();
},
});
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
export const getItem = pikkuSessionlessFunc({
expose: true,
description: 'Get a single item by ID.',
input: GetItemInput,
output: GetItemOutput,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.innerJoin(
'category',
'category.categoryId',
'item.categoryId',
)
.select([
'item.itemId',
'item.name',
'item.slug',
'item.description',
'item.priceCents',
'item.stock',
'item.imageUrl',
'item.isActive',
'item.createdAt',
'item.updatedAt',
'category.categoryId',
'category.name as categoryName',
'category.slug as categorySlug',
])
.where('item.itemId', '=', itemId)
.executeTakeFirst();

if (!row) throw new Error(`Item not found: ${itemId}`);

return {
...row,
category: {
categoryId: row.categoryId,
name: row.categoryName,
slug: row.categorySlug,
},
};
},
});
wirings.tswiring.ts
// The same function can be wired to multiple transports without any changes.
// Define once, wire everywhere.
wireHTTP({
method: 'get',
route: '/items/:itemId',
func: getItem,
auth: false,
});
Zero duplicationBusiness logic lives in one place. Each wire adapts the protocol to your function.
Same permissionsAuth and permission checks apply regardless of which wire triggers the function.
Same typesInput 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