
PocketBase Hooks
FreeExtend PocketBase with custom server-side logic.
Free · Opens the source repo
What PocketBase Hooks does
PocketBase Hooks provides a framework for implementing server-side JavaScript in PocketBase applications. This skill allows developers to create custom routes, handle events, and manage cron jobs, all using a familiar JavaScript syntax. Built on the goja ES5 runtime, it supports essential JavaScript features while avoiding ES6 modules and async/await patterns, making it suitable for environments where compatibility is crucial.
The skill enables developers to define routes easily, using a straightforward syntax to handle various HTTP methods and response types. It supports middleware for request processing, allowing for authentication checks, body limits, and compression, which can be applied globally or on a per-route basis. This flexibility makes it a powerful tool for building robust APIs and handling complex request scenarios.
Event hooks are a key feature, providing lifecycle management for database records. Developers can respond to events such as record creation, updates, and deletions, enabling them to implement custom logic before or after these actions. The hooks also allow for real-time updates and authentication processes, enhancing the interactivity and security of applications built on PocketBase.
Overall, PocketBase Hooks is designed for developers looking to enhance their PocketBase applications with server-side logic. It is particularly useful for those who need to create custom APIs, manage user authentication, and handle complex data interactions without compromising on performance or scalability.
When to use it
Use PocketBase Hooks when you need to implement custom server-side functionality in your PocketBase applications, such as creating APIs or handling events.
When not to use it
This skill may not be suitable for projects that require extensive use of ES6 features or async programming, as it is limited to ES5.1 and lacks support for modern JavaScript syntax.
What you can build with it
Creating Custom API Endpoints
Use PocketBase Hooks to define custom API routes that handle specific requests and return tailored responses.
Implementing User Authentication
Leverage event hooks to manage user authentication processes, including password and OAuth2 login flows.
Handling Data Lifecycle Events
Utilize record lifecycle hooks to enforce business logic during the creation, update, or deletion of database records.
How to install PocketBase Hooks
View source1. Install with the skills CLI
npx skills add davila7/claude-code-templates/pb-hooks --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by davila7PocketBase Server-Side JavaScript (pb_hooks)
Runtime Basics
- Files go in
pb_hooks/*.pb.js(must end with.pb.js) - Engine: goja — ES5.1 + some ES6. No ES6 modules (
import/export), no async/await, no arrow functions in older versions. Usefunction(){}and CommonJSrequire(). - Each file is loaded on app start and on hot-reload
__hooks— absolute path to the pb_hooks directory- TypeScript declarations:
pb_data/types.d.ts(auto-generated, useful for IDE support) --hooksPool=25flag controls concurrent JS goroutines (default: 25)- Each handler runs in an isolated context — no shared mutable state between requests
Routing
Adding routes
routerAdd("GET", "/api/hello/{name}", function(e) {
var name = e.request.pathValue("name")
return e.json(200, { "message": "Hello " + name })
}, /* optional middleware */)
Path patterns
{name}— named path parameter{path...}— wildcard (matches rest of path){$}— exact match (no trailing slash)
Response methods
| Method | Usage |
|---|---|
e.json(status, data) | JSON response |
e.string(status, text) | Plain text |
e.html(status, html) | HTML response |
e.redirect(status, url) | Redirect (301/302) |
e.blob(status, contentType, bytes) | Binary data |
e.stream(status, contentType, reader) | Streaming response |
e.noContent(status) | No body (204) |
Reading request data
// Body (JSON)
var body = new DynamicModel({ name: "", age: 0 })
e.bindBody(body)
// Query params
var page = e.request.url.query().get("page")
// Headers
var token = e.request.header.get("Authorization")
// Uploaded files
var files = e.findUploadedFiles("document") // returns array of *filesystem.File
// Auth state
var user = e.auth // current auth record or null
var isSuper = e.hasSuperuserAuth()
Middleware
Built-in middleware
routerAdd("GET", "/api/protected", handler,
$apis.requireAuth(), // any authenticated user
// OR
$apis.requireAuth("users"), // only "users" collection
// OR
$apis.requireSuperuserAuth(), // superusers only
// OR
$apis.requireGuestOnly(), // unauthenticated only
// OR
$apis.bodyLimit(5 * 1024 * 1024), // 5MB body limit
// OR
$apis.gzip() // gzip compression
)
Global middleware
routerUse(function(e) {
// runs before every request
console.log(e.request.method, e.request.url.path)
return e.next() // MUST call e.next() to continue
})
Custom route middleware
function myMiddleware(e) {
// pre-processing
var result = e.next() // call next handler
// post-processing
return result
}
routerAdd("GET", "/api/test", handler, myMiddleware)
Priority: middleware runs in order — first registered, first executed.
Event Hooks
Record lifecycle
Each record event has 3 variants:
onRecord*Execute— wraps the default action. Calle.next()to proceed.onRecord*AfterSuccess— runs after successful executiononRecord*AfterError— runs after execution error
// Before/during create
onRecordCreateExecute(function(e) {
// e.record — the record being created
e.record.set("status", "pending")
return e.next() // proceed with creation
}, "posts") // optional collection filter
// After successful create
onRecordAfterCreateSuccess(function(e) {
// e.record — the created record (has ID now)
console.log("Created:", e.record.id)
}, "posts")
// After failed create
onRecordAfterCreateError(function(e) {
// e.error — the error
console.log("Failed:", e.error)
}, "posts")
All record hooks
| Hook | Event object fields |
|---|---|
onRecordCreateExecute | e.record |
onRecordUpdateExecute | e.record |
onRecordDeleteExecute | e.record |
onRecordAfterCreateSuccess | e.record — after successful create |
onRecordAfterUpdateSuccess | e.record — after successful update |
onRecordAfterDeleteSuccess | e.record — after successful delete |
onRecordAfterCreateError | e.record, e.error — after failed create |
onRecordAfterUpdateError | e.record, e.error — after failed update |
onRecordAfterDeleteError | e.record, e.error — after failed delete |
onRecordValidate | e.record — add custom validation errors |
onRecordEnrich | e.record — modify API response (hide/add fields) |
onRecordsListRequest | e.records, e.result — modify list response |
onRecordRequestCreate | e.record — during API create request |
onRecordRequestUpdate | e.record — during API update request |
onRecordRequestDelete | e.record — during API delete request |
Auth hooks
onRecordAuthWithPasswordRequest(function(e) {
// e.record — the auth record
// e.password — the provided password
return e.next()
}, "users")
onRecordAuthWithOAuth2Request(function(e) {
// e.record — the auth record (may be new)
// e.oAuth2User — OAuth2 user data
// e.isNewRecord — true if first OAuth2 login
return e.next()
}, "users")
onRecordAuthWithOTPRequest(function(e) {
// e.record — the auth record
return e.next()
}, "users")
onRecordAuthRefreshRequest(function(e) {
return e.next()
}, "users")
Realtime hooks
onRealtimeConnectRequest(function(e) {
// e.client — the SSE client
// e.idleTimeout — connection timeout
return e.next()
})
onRealtimeSubscribeRequest(function(e) {
// e.client
// e.subscriptions — requested subscriptions
return e.next()
})
Other hooks
onFileDownloadRequest(function(e) {
// e.record, e.fileField, e.servedPath, e.servedName
return e.next()
}, "documents")
onBatchRequest(function(e) {
// e.batch — array of sub-requests
return e.next()
})
onCollectionCreateExecute(function(e) {
// e.collection
return e.next()
})
// App lifecycle
onBootstrap(function(e) {
// runs once on app start (after DB is ready)
return e.next()
})
onTerminate(function(e) {
// runs on graceful shutdown
return e.next()
})
Validation hook
onRecordValidate(function(e) {
if (e.record.getString("title").length < 3) {
e.error = new ValidationError("title", "Title must be at least 3 characters")
}
return e.next()
}, "posts")
Enrich hook (modify API response)
onRecordEnrich(function(e) {
// Hide field from non-owners
if (!e.requestInfo.auth || e.requestInfo.auth.id !== e.record.getString("author")) {
e.record.hide("private_notes")
}
// Add computed field
e.record.withCustomData(true)
e.record.set("displayName", e.record.getString("first") + " " + e.record.getString("last"))
return e.next()
}, "users")
Database
Query builder
var results = arrayOf(new DynamicModel({ id: "", title: "", count: 0 }))
$app.db()
.select("id", "title", "COUNT(comments) as count")
.from("posts")
.where($dbx.hashExp({ status: "active" }))
.andWhere($dbx.like("title", "hello"))
.orderBy("created DESC")
.limit(10)
.offset(0)
.all(results) // populates results array
Execution methods
| Method | Returns |
|---|---|
.all(results) | Populates array |
.one(result) | Single record |
.execute() | For INSERT/UPDATE/DELETE |
Raw queries
$app.db().newQuery("SELECT * FROM posts WHERE status = {:status}")
.bind({ status: "active" })
.all(results)
Always use named params {:param} — never concatenate SQL strings.
$dbx expressions
$dbx.hashExp({ field: "value" }) // field = "value"
$dbx.hashExp({ field: ["a", "b"] }) // field IN ("a", "b")
$dbx.not($dbx.hashExp({ field: "value" })) // NOT (field = "value")
$dbx.and(expr1, expr2) // expr1 AND expr2
$dbx.or(expr1, expr2) // expr1 OR expr2
$dbx.like("field", "val") // field LIKE "%val%"
$dbx.orLike("field", "a", "b") // field LIKE "%a%" OR field LIKE "%b%"
$dbx.notLike("field", "val") // field NOT LIKE "%val%"
$dbx.in("field", "a", "b", "c") // field IN ("a", "b", "c")
$dbx.notIn("field", "a", "b") // field NOT IN ("a", "b")
$dbx.between("field", 1, 10) // field BETWEEN 1 AND 10
$dbx.exists($dbx.exp("SELECT 1 FROM t WHERE ..."))
$dbx.exp("raw SQL expression", optionalParams)
Transactions
$app.runInTransaction(function(txApp) {
// use txApp instead of $app inside transaction
var record = txApp.findRecordById("posts", "RECORD_ID")
record.set("views", record.getInt("views") + 1)
txApp.save(record)
})
Record Operations
Find records
// By ID
var record = $app.findRecordById("posts", "RECORD_ID")
// By field value
var record = $app.findFirstRecordByData("users", "email", "user@example.com")
// By filter expression (same syntax as API rules)
var record = $app.findFirstRecordByFilter("posts", "slug = {:slug}", { slug: "my-post" })
// Multiple records with filter
var records = $app.findRecordsByFilter(
"posts", // collection
"status = 'active'", // filter
"-created", // sort
10, // limit
0 // offset
)
// All records (no limit)
var records = $app.findAllRecords("posts", $dbx.hashExp({ status: "active" }))
// Count
var total = $app.countRecords("posts", $dbx.hashExp({ status: "active" }))
Create records
var collection = $app.findCollectionByNameOrId("posts")
var record = new Record(collection)
record.set("title", "My Post")
record.set("author", "USER_ID")
record.set("tags", ["tag1", "tag2"]) // multi-relation
$app.save(record)
// record.id is now set
Update records
var record = $app.findRecordById("posts", "RECORD_ID")
record.set("title", "Updated Title")
$app.save(record)
Delete records
var record = $app.findRecordById("posts", "RECORD_ID")
$app.delete(record)
Record getters
record.id
record.getString("title")
record.getInt("count")
record.getFloat("price")
record.getBool("active")
record.getStringSlice("tags") // for multi-valued fields
record.getDateTime("created") // returns DateTime object
record.get("field") // raw interface{} value
Expand relations
$app.expandRecord(record, ["author", "tags"], null)
var author = record.expandedOne("author") // single relation
var tags = record.expandedAll("tags") // multi relation
File operations
// Assign file from path
var file = $filesystem.fileFromPath("/path/to/file.pdf")
record.set("document", file)
// Assign file from bytes
var file = $filesystem.fileFromBytes(byteArray, "report.pdf")
record.set("document", file)
// Assign file from URL
var file = $filesystem.fileFromURL("https://example.com/file.pdf")
record.set("document", file)
$app.save(record)
Cron Jobs
cronAdd("daily_cleanup", "0 3 * * *", function() {
// runs every day at 3:00 AM
var old = $app.findRecordsByFilter("temp", "created < @now - 30d", "", 0, 0)
for (var i = 0; i < old.length; i++) {
$app.delete(old[i])
}
})
cronRemove("daily_cleanup") // remove a previously registered job
Cron expressions: minute hour day month weekday
Preview registered crons: Dashboard > Settings > Crons
var message = new MailerMessage()
message.from = { address: $app.settings().meta.senderAddress, name: $app.settings().meta.senderName }
message.to = [{ address: "user@example.com", name: "User" }]
message.subject = "Hello"
message.html = "<h1>Hello World</h1>"
// message.bcc, message.cc — optional arrays
// message.attachments — optional
$app.newMailClient().send(message)
Customize system emails
onMailerRecordVerificationSend(function(e) {
// e.record, e.message
e.message.subject = "Custom verification subject"
e.message.html = "<p>Custom HTML with token: " + e.meta.token + "</p>"
return e.next()
}, "users")
// Similar hooks: onMailerRecordResetPasswordSend, onMailerRecordEmailChangeSend, onMailerRecordOTPSend
HTTP Client
var res = $http.send({
url: "https://api.example.com/data",
method: "POST",
body: JSON.stringify({ key: "value" }),
headers: { "Content-Type": "application/json", "Authorization": "Bearer TOKEN" },
timeout: 30 // seconds
})
// Response
res.statusCode // number
res.json // parsed JSON (if applicable)
res.headers // object
res.cookies // object
res.body // raw string
// Multipart upload
var formData = new FormData()
formData.append("file", $filesystem.fileFromPath("/path/to/file.pdf"))
formData.append("name", "test")
var res = $http.send({
url: "https://api.example.com/upload",
method: "POST",
body: formData
})
No streaming support in $http.send().
Error Types
throw new BadRequestError("message", optionalData) // 400
throw new UnauthorizedError("message", optionalData) // 401
throw new ForbiddenError("message", optionalData) // 403
throw new NotFoundError("message", optionalData) // 404
throw new TooManyRequestsError("message", optionalData) // 429
throw new InternalServerError("message", optionalData) // 500
throw new ApiError(statusCode, "message", optionalData) // custom status
// Validation errors (for onRecordValidate)
new ValidationError("field_name", "error message")
Global Objects
| Object | Purpose |
|---|---|
$app | Main app instance — DB, records, collections, settings |
$apis | API middleware helpers |
$security | JWT, encryption, random string generation |
$os | OS operations: $os.exec(), $os.readDir(), $os.tempDir() |
$http | HTTP client |
$filesystem | File helpers (fileFromPath, fileFromBytes, fileFromURL) |
$dbx | SQL expression builders |
$security examples
var token = $security.randomString(32)
var hash = $security.hs256("data", "secret")
var encrypted = $security.encrypt("data", "encryptionKey")
var decrypted = $security.decrypt(encrypted, "encryptionKey")
$os examples
var result = $os.exec("ls", ["-la", "/tmp"]) // returns { code, output }
var files = $os.readDir("/path")
var tmp = $os.tempDir("prefix")
Common Patterns
Auto-assign author on create
onRecordCreateExecute(function(e) {
if (e.auth) {
e.record.set("author", e.auth.id)
}
return e.next()
}, "posts")
Cascade custom logic on delete
onRecordDeleteExecute(function(e) {
// Clean up related data not handled by cascadeDelete
var comments = $app.findRecordsByFilter("comments", "post = {:id}", "-created", 0, 0, { id: e.record.id })
for (var i = 0; i < comments.length; i++) {
$app.delete(comments[i])
}
return e.next()
}, "posts")
Rate limiting per user
routerAdd("POST", "/api/expensive-action", function(e) {
var recent = $app.countRecords("actions",
$dbx.hashExp({ user: e.auth.id }),
$dbx.exp("created > {:cutoff}", { cutoff: new DateTime().sub(1 * 60) }) // last minute
)
if (recent >= 5) {
throw new TooManyRequestsError("Rate limit exceeded")
}
// proceed with action
return e.json(200, { ok: true })
}, $apis.requireAuth())
Webhook on record change
onRecordCreateAfterSuccessExecute(function(e) {
try {
$http.send({
url: "https://hooks.example.com/webhook",
method: "POST",
body: JSON.stringify({
event: "record.create",
collection: e.record.collection().name,
record: e.record
}),
headers: { "Content-Type": "application/json" },
timeout: 10
})
} catch (err) {
console.log("Webhook failed:", err)
}
})
Frequently asked questions about PocketBase Hooks
Similar skills
WinMD API Search
Easily find and explore Windows desktop APIs.
WebMCPify
Transform any web app into an agent-ready platform.
Phoenix Tracing
Instrument LLM applications with OpenInference tracing.
Foundry Hosted Agent CopilotKit
Guidance for developing agentic web apps on Azure.
Power Automate Foundation
Connect AI agents to Power Automate seamlessly.
Power Automate Flow Builder
Efficiently build and deploy Power Automate flows programmatically.
