Claude-code-templates PocketBase Hooks
Server-side JavaScript hooks for PocketBase (pb_hooks). Use when writing custom routes, event hooks, cron jobs, sending emails, making HTTP requests, querying the database, or extending PocketBase with server-side logic. Covers the goja ES5 runtime, routing, middleware, all event hooks, DB queries, record operations, and global APIs.
install
source · Clone the upstream repo
git clone https://github.com/davila7/claude-code-templates
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/davila7/claude-code-templates "$T" && mkdir -p ~/.claude/skills && cp -r "$T/cli-tool/components/skills/pocketbase/pb-hooks" ~/.claude/skills/davila7-claude-code-templates-pocketbase-hooks && rm -rf "$T"
manifest:
cli-tool/components/skills/pocketbase/pb-hooks/SKILL.mdsource content
PocketBase Server-Side JavaScript (pb_hooks)
Runtime Basics
- Files go in
(must end withpb_hooks/*.pb.js
).pb.js - Engine: goja — ES5.1 + some ES6. No ES6 modules (
/import
), no async/await, no arrow functions in older versions. Useexport
and CommonJSfunction(){}
.require() - Each file is loaded on app start and on hot-reload
— absolute path to the pb_hooks directory__hooks- TypeScript declarations:
(auto-generated, useful for IDE support)pb_data/types.d.ts
flag controls concurrent JS goroutines (default: 25)--hooksPool=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
— named path parameter{name}
— wildcard (matches rest of path){path...}
— exact match (no trailing slash){$}
Response methods
| Method | Usage |
|---|---|
| JSON response |
| Plain text |
| HTML response |
| Redirect (301/302) |
| Binary data |
| Streaming response |
| 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:
— wraps the default action. CallonRecord*Execute
to proceed.e.next()
— runs after successful executiononRecord*AfterSuccess
— runs after execution erroronRecord*AfterError
// 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 |
|---|---|
| |
| |
| |
| — after successful create |
| — after successful update |
| — after successful delete |
| , — after failed create |
| , — after failed update |
| , — after failed delete |
| — add custom validation errors |
| — modify API response (hide/add fields) |
| , — modify list response |
| — during API create request |
| — during API update request |
| — 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 |
|---|---|
| Populates array |
| Single record |
| For INSERT/UPDATE/DELETE |
Raw queries
$app.db().newQuery("SELECT * FROM posts WHERE status = {:status}") .bind({ status: "active" }) .all(results)
Always use named params
— never concatenate SQL strings.{:param}
$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 |
|---|---|
| Main app instance — DB, records, collections, settings |
| API middleware helpers |
| JWT, encryption, random string generation |
| OS operations: , , |
| HTTP client |
| File helpers (, , ) |
| 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) } })