Functions
Functions are at the heart of Pikku. They contain your application's domain logic - the "what" your application does, completely separate from "how" it's accessed.
The beauty of Pikku functions is that they're transport-agnostic. Write your function once, and it can be called via HTTP REST API, WebSocket messages, background queue jobs, scheduled cron tasks, CLI commands, or even as an MCP tool for AI agents. The function doesn't need to know or care.
Your First Function
Here's a simple function that fetches a book from a database:
import { pikkuFunc } from '#pikku'
export const getBook = pikkuFunc<{ bookId: string }, Book>({
func: async ({ database }, data) => {
try {
return await database.query('book', { bookId: data.bookId })
} catch (e) {
throw new NotFoundError()
}
},
title: 'Fetch a book by ID',
description: 'Returns a book from the database',
tags: ['books']
})
This function can now be wired to:
- A
GET /books/:bookIdHTTP endpoint - A WebSocket action
{ action: 'getBook', bookId: '123' } - A queue worker that processes book lookup jobs
- A CLI command
myapp book get <bookId> - An MCP resource that AI agents can query
All without changing a single line of the function code.
Think of Pikku functions like serverless functions (Lambda, Cloudflare Workers) but without the lock-in. You write pure business logic that's completely decoupled from the runtime. The difference? You can call the same function from HTTP, WebSocket, a queue, or anything else – and deploy it to any platform. One function, infinite entry points.
The Function Signature
Pikku functions use an object configuration with a func property that contains your logic:
const myFunction = pikkuFunc<InputType, OutputType>({
func: async ({ database, logger }, data, { session }) => {
// Your logic here - destructure only the services you need
return result
},
// Optional configuration
auth: true,
permissions: { /* ... */ },
title: '...',
description: '...',
tags: ['...']
})
Parameters Explained
1. Services - Your application's singleton services (database, cache, logger, etc.)
Always destructure services directly in the parameter list - this is critical for Pikku to tree-shake unused services and optimize your bundle:
func: async ({ database, logger }, data) => {
logger.info('Fetching book', { bookId: data.bookId })
// Only database and logger are included in the bundle
}
If you need access to transport-specific information (like HTTP headers or WebSocket channel info), you can destructure from the wire parameter:
func: async ({ database }, data, { http }) => {
if (http) {
const userAgent = http.headers['user-agent']
}
// Destructure what you need: { http }, { channel }, { queue }, etc.
}
2. Data - The input to your function
Pikku automatically merges data from wherever it comes from - URL paths, query parameters, request bodies, WebSocket messages, queue payloads, etc. Your function just receives clean, typed data.
Automatic Validation: Pikku automatically generates JSON schemas from your TypeScript input types and validates all incoming data against them. If the data doesn't match your type signature, the function won't even be called - an error is returned immediately.
type CreateBookInput = {
title: string
author: string
publishedYear?: number
}
export const createBook = pikkuFunc<CreateBookInput, Book>({
func: async ({ database }, data) => {
// data is guaranteed to match CreateBookInput
// title and author are strings, publishedYear is optional number
return await database.insert('book', data)
},
title: 'Create a new book',
tags: ['books']
})
3. Wire - Access to session and transport-specific details
When you need the user's session or transport-specific information, destructure from the third parameter:
func: async ({ database }, data, { session }) => {
// session IS the user session value directly
const userId = session?.userId
}
Sessions and Authentication
By default, Pikku functions require authentication (auth: true). This means a user session must exist for the function to execute.
Authentication Functions (Login, Logout, GetMe)
loading...
The login function uses jwt.encode to issue a token and returns it alongside the user. The logout function calls clearSession() to clear the session. The getMe function reads the current session to return the authenticated user.
The session from the wire parameter abstracts session storage, so it works identically whether your users are connecting via HTTP cookies, WebSocket connections, or any other transport that requires a session.
See User Sessions for more details on session management.
Permissions
Functions can declare fine-grained permissions for authorization:
export const deleteBook = pikkuFunc<{ bookId: string }, void>({
func: async ({ database }, data) => {
await database.delete('book', { bookId: data.bookId })
},
permissions: {
// User must be either the owner OR an admin
owner: requireBookOwner,
admin: requireAdmin
},
title: 'Delete a book',
tags: ['books']
})
Permissions are defined separately and can be reused across functions:
// permissions.ts
export const requireBookOwner: PikkuPermission<{ bookId: string }> =
async ({ database }, data, { session }) => {
// session IS the user session value directly
if (!session?.userId) return false
const book = await database.query('book', {
bookId: data.bookId,
ownerId: session.userId
})
return !!book
}
See Permission Guards for more details.
Calling Functions from Functions
Sometimes one function needs to call another. Use rpc.invoke() for this - it's for internal function-to-function calls (in the future this could also mean between microservices), and still enforces all permissions, auth, and function middleware:
export const processOrder = pikkuFunc<{ orderId: string }, Order>({
func: async ({ database, rpc }, data) => {
// Orchestrate multiple domain operations
// Each invoke still enforces permissions and auth
const invoice = await rpc.invoke('generateInvoice', {
orderId: data.orderId
})
const payment = await rpc.invoke('processPayment', {
invoiceId: invoice.id
})
return await database.update('order', {
where: { orderId: data.orderId },
set: { status: 'completed', paymentId: payment.id }
})
},
title: 'Process an order end-to-end',
tags: ['orders']
})
RPC calls are great for orchestrating complex workflows while maintaining security boundaries. Each rpc.invoke() still runs through the full auth and permission checks.
See RPC (Remote Procedure Calls) for more details on when and how to use RPC.
Error Handling
Errors in Pikku are thrown, not returned. Use built-in error classes or extend PikkuError:
import { BadRequestError, NotFoundError } from '@pikku/core/errors'
export const updateBook = pikkuFunc<UpdateBookInput, Book>({
func: async ({ database }, data) => {
if (!data.title || data.title.length < 1) {
throw new BadRequestError('Title is required')
}
try {
return await database.update('book', {
where: { bookId: data.bookId },
set: { title: data.title }
})
} catch (e) {
throw new NotFoundError('Book not found')
}
},
title: 'Update a book',
tags: ['books']
})
When called via HTTP, BadRequestError becomes a 400 response and NotFoundError becomes a 404. When called via WebSocket, they become error messages. The function doesn't need to handle this - Pikku does it automatically.
See Errors for more on error handling and creating custom errors.
Function Metadata
Functions support metadata properties for documentation and API generation:
export const myFunc = pikkuFunc<Input, Output>({
func: async (services, data) => { ... },
title: 'A short one-liner describing what this does',
description: 'Optional longer description with more context',
tags: ['category', 'grouping'],
})
This metadata is used to:
- Generate OpenAPI specifications for your HTTP APIs
- Create type-safe clients
- Build developer documentation
- Help AI agents understand your MCP tools
Input and Output Validation
Pikku supports runtime validation using Zod:
import { z } from 'zod'
export const createBook = pikkuFunc<CreateBookInput, Book>({
func: async ({ database }, data) => {
return await database.insert('book', data)
},
input: z.object({
title: z.string().min(1),
author: z.string(),
publishedYear: z.number().optional()
}),
output: z.object({
id: z.string(),
title: z.string(),
author: z.string()
}),
title: 'Create a new book',
tags: ['books']
})
The input and output schemas provide runtime validation on top of the compile-time TypeScript type checking.
Visibility Control
Control how your functions are exposed:
export const internalHelper = pikkuFunc<Input, Output>({
func: async (services, data) => { ... },
internal: true // Not exposed via external RPC
})
export const publicAPI = pikkuFunc<Input, Output>({
func: async (services, data) => { ... },
expose: true // Explicitly exposed via RPC
})
expose: true- Makes the function available for external RPC callsinternal: true- Marks the function as internal-only (not exposed externally)
Organizing Your Code
A typical Pikku project structure looks like:
packages/functions/src/
functions/
books.function.ts # Your domain functions
auth.function.ts
orders.function.ts
services/
database.service.ts # Plain TypeScript services
email.service.ts
services.ts # Service factory
permissions.ts # Permission guards
middleware.ts # Middleware definitions
errors.ts # Custom error types
config.ts # Configuration
Functions live in *.function.ts files and only export Pikku functions. Services are plain TypeScript classes that don't depend on Pikku - this keeps your business logic portable and easy to test.
By keeping your services as plain TypeScript (no Pikku dependencies), you can:
- Test functions in isolation - Just call
myFunction.func(mockServices, mockData, mockSession) - Reuse logic elsewhere - Your database service can be used in scripts, migrations, or other tools
- Avoid lock-in - If you ever move away from Pikku, your core logic stays intact
This is intentional: Pikku is just the glue between your logic and the outside world. Your actual business logic should be framework-independent.
This structure isn't required - you can organize your code however you want. This is just what's most tested and tree-shakable.
Next Steps
Now that you understand how functions work, learn how to connect them to the outside world: