Pikku Functions Skill
This skill helps you write Pikku functions that are transport-agnostic, type-safe, and follow the framework's core principles.
When to use this skillβ
- Creating new Pikku functions
- Refactoring existing functions to follow Pikku patterns
- Implementing domain logic in a Pikku project
- Setting up permissions, auth, or middleware for functions
- Working with RPC calls between functions
- Structuring services and function interactions
Core Function Syntaxβ
All domain functions must use the object form of pikkuFunc
/ pikkuFuncSessionless
.
pikkuFunc<In, Out>({
func: async (services, data, session) => Out, // MUST be async
permissions?: Record<string, PikkuPermission[] | PikkuPermission>,
auth?: true | false, // defaults to true
expose?: true | false, // if exposed as a public RPC/client API
docs?: { summary: string; description?: string; tags?: string[]; errors?: string[] }
})
Sessionless variant:
pikkuFuncSessionless<In, Out>({
func: async (services, data) => Out, // MUST be async
// same options (minus session usage)
})
Critical Rulesβ
CRITICAL: Always destructure services in parametersβ
β Correct:
func: async ({ kysely, eventHub }, data) => {
// use kysely and eventHub directly
}
β Wrong:
func: async (services, data) => {
const { kysely } = services // DON'T DO THIS
}
Other Core Rulesβ
- No manual auth checks: Rely on
auth
(defaulttrue
) andpermissions
/middleware - Errors are thrown, not returned: Must extend
PikkuError
- Cross-function calls use RPC:
rpc.invoke('<ExactExportName>', input)
β never import another Pikku function directly - Public APIs must set
expose: true
: So generated client types include it - Always import from generated types: Import
pikkuFunc
andpikkuFuncSessionless
from#pikku/pikku-types.gen.js
, never from@pikku/core
RPC Usage Rulesβ
rpc.invoke()
is only for non-trivial, reusable domain functions:
- Orchestration
- Transactions
- Shared validation/permissions
- Cross-resource invariants
- Long-running flows
For simple CRUD or one-service calls, call the service directly. Do not wrap trivial reads/writes behind rpc
.
β Good:
await rpc.invoke('generateInvoice', { orderId }) // orchestrates multiple steps/rules
β Avoid:
await rpc.invoke('loadCard', { cardId }) // trivial; prefer services.store.getCard(cardId)
This keeps call graphs clear, prevents cycles, and reduces overhead.
Project Structureβ
packages/functions/src/
functions/*.function.ts # domain functions only
services/*.ts # service classes/interfaces (Pikku-agnostic by default)
services.ts # service assembly (typed factories)
errors.ts # project-specific errors (prefer importing core errors)
permissions.ts # PikkuPermission definitions
middleware.ts # PikkuMiddleware definitions
config.ts # createConfig() implementation
Functions (*.function.ts
)β
- Allowed imports: local types,
pikkuFunc
/pikkuFuncSessionless
, error/permission/middleware symbols - No wiring/adapters/env/globals in these files
- Private helpers allowed if not exported
Servicesβ
- Services live in
services/**
and should be Pikku-agnostic by default - Service assembly happens only in
services.ts
Permissionsβ
A permission is a boolean-returning guard with the same parameters as a Pikku function.
IMPORTANT: Always use the object syntax with name
and description
metadata for better AI understanding and documentation.
export const requireOwner = pikkuPermission<{
resourceOwnerId: string
}>({
name: 'Require Owner',
description: 'Verifies that the current user owns the specified resource',
func: async ({ ownership }, data, session) => {
if (!session?.userId) return false
return ownership.isOwner(session.userId, data.resourceOwnerId)
},
})
Direct function syntax (discouraged):
export const requireOwner: PikkuPermission<{
resourceOwnerId: string
}> = async ({ ownership }, data, session) => {
if (!session?.userId) return false
return ownership.isOwner(session.userId, data.resourceOwnerId)
}
Attach permissions to functions via the permissions
property. Prefer function-level permissions; use transport-level overrides sparingly.
Middlewareβ
Middleware wraps a Pikku function before/after execution.
IMPORTANT: Always use the object syntax with name
and description
metadata for better AI understanding and documentation.
CRITICAL: Always guard for the interaction type. If your middleware EXPECTS a specific interaction, throw an error instead of failing silently.
The interaction
object contains different properties depending on the transport:
interaction.http
- HTTP requests (hasmethod
,path
,headers
, etc.)interaction.queue
- Queue jobs (hasqueueName
,jobId
,updateProgress
,fail
,discard
)interaction.channel
- WebSocket channels (has channel info)interaction.scheduledTask
- Scheduled tasksinteraction.mcp
- MCP interactionsinteraction.rpc
- RPC calls
Example 1: Middleware that works across transports (with metadata):
export const audit = pikkuMiddleware({
name: 'Audit Logger',
description:
'Logs execution time and user info for all function calls across any transport',
func: async ({ userSession, logger }, interaction, next) => {
const t0 = Date.now()
try {
await next()
} finally {
const userId = await userSession.get('userId').catch(() => undefined)
// Optional: Log different info based on transport
if (interaction.http) {
logger?.info?.('audit', {
method: interaction.http.method,
path: interaction.http.path,
userId,
ms: Date.now() - t0,
})
} else if (interaction.queue) {
logger?.info?.('audit', {
queueName: interaction.queue.queueName,
jobId: interaction.queue.jobId,
userId,
ms: Date.now() - t0,
})
}
}
},
})
Example 2: Middleware that REQUIRES a specific interaction (HTTP-only, with metadata):
import { InvalidMiddlewareInteractionError } from '@pikku/core/errors'
export const requireHTTPS = pikkuMiddleware({
name: 'Require HTTPS',
description:
'Enforces HTTPS for all HTTP requests, rejects non-HTTPS connections',
func: async ({ logger }, interaction, next) => {
// β
CRITICAL: If middleware expects HTTP, throw error if not present
if (!interaction.http) {
throw new InvalidMiddlewareInteractionError(
'requireHTTPS middleware can only be used with HTTP interactions'
)
}
// Now we can safely access HTTP-specific properties
if (interaction.http.headers['x-forwarded-proto'] !== 'https') {
throw new ForbiddenError('HTTPS required')
}
await next()
},
})
Direct function syntax (discouraged):
export const audit = pikkuMiddleware(
async ({ userSession, logger }, interaction, next) => {
// ... implementation
}
)
When to throw vs. when to guard:
- β Silent fail: Don't silently skip middleware logic if you need a specific interaction
- β
Throw error: If middleware is transport-specific (e.g., HTTP-only), throw
InvalidMiddlewareInteractionError
- β
Optional guard: If middleware adapts to different transports, use
if (interaction.http)
guards
Note: Consider adding InvalidMiddlewareInteractionError
to @pikku/core/errors
(maps to 500 status code)
userSessionβ
The userSession
service allows you to set and clear session data across any protocol (HTTP, WebSocket, etc.).
Setting/upserting the session:
// β
CORRECT: Pass the entire session object to userSession.set()
// This upserts the session data
await userSession.set({ userId: user.id, role: user.role })
Clearing the session (logout):
// β
CORRECT: Clear the session
await userSession.clear()
Getting session values:
const userId = await userSession.get('userId')
const role = await userSession.get('role')
Key points:
- Use
userSession.set()
to upsert session data (login, authentication) - Use
userSession.clear()
to clear session data (logout) - Session data is stored in a store (local or remote, depending on your persistence strategy)
- Works across any protocol (HTTP, WebSocket, Queue, Scheduler, MCP)
- Do not manually check for session presence in functions; rely on
auth
and permissions
EventHub (transport-agnostic pub/sub)β
Use EventHub for topic-based fan-out across channels, SSE, queues, or internal events.
await eventHub.subscribe(topic, channel.channelId)
await eventHub.unsubscribe(topic, channel.channelId)
await eventHub.publish(topic, null, payload) // broadcast to all
await eventHub.publish(topic, channel.channelId, payload) // exclude/target (adapter dependent)
Required Documentationβ
Every function includes a docs
block:
docs: {
summary: 'Fetch a card',
description: 'Returns a card by ID',
tags: ['cards'],
errors: ['NotFoundError'],
}
Examplesβ
See the examples/
directory for complete function examples including:
- Basic read function (exposed RPC)
- Mutation using RPC for orchestration
- Sessionless health check
- Permission guards
- Middleware usage
Review Checklistβ
When creating or reviewing Pikku functions:
- Files live under
packages/functions/src/
with.function.ts
suffix - Functions are async and destructure services IN THE PARAMETER LIST
- No wiring/adapters/env/globals inside function files
-
rpc.invoke
used only when non-trivial reuse is intended - Services are Pikku-agnostic by default and assembled in
services.ts
- Errors extend
PikkuError
- Every function has a
docs
block - Permissions and middleware use object syntax with
name
anddescription
metadata - No
any
or@ts-ignore
without justification
Code Styleβ
- Always use
async
/await
; do not use.then()
/.catch()
for control flow - Use
try/catch
only when there is something meaningful to handle/log; otherwise let errors bubble