Agent skill
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 this agent skill to your Project
npx add-skill https://github.com/davila7/claude-code-templates/tree/main/cli-tool/components/skills/pocketbase/pb-hooks
SKILL.md
PocketBase 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)
}
})
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
verl-rl-training
Provides guidance for training LLMs with reinforcement learning using verl (Volcano Engine RL). Use when implementing RLHF, GRPO, PPO, or other RL algorithms for LLM post-training at scale with flexible infrastructure backends.
openrlhf-training
High-performance RLHF framework with Ray+vLLM acceleration. Use for PPO, GRPO, RLOO, DPO training of large models (7B-70B+). Built on Ray, vLLM, ZeRO-3. 2× faster than DeepSpeedChat with distributed architecture and GPU resource sharing.
gguf-quantization
GGUF format and llama.cpp quantization for efficient CPU/GPU inference. Use when deploying models on consumer hardware, Apple Silicon, or when needing flexible quantization from 2-8 bit without GPU requirements.
Claude Code Guide
Master guide for using Claude Code effectively. Includes configuration templates, prompting strategies "Thinking" keywords, debugging techniques, and best practices for interacting with the agent.
qdrant-vector-search
High-performance vector similarity search engine for RAG and semantic search. Use when building production RAG systems requiring fast nearest neighbor search, hybrid search with filtering, or scalable vector storage with Rust-powered performance.
behavioral-modes
AI operational modes (brainstorm, implement, debug, review, teach, ship, orchestrate). Use to adapt behavior based on task type.
Didn't find tool you were looking for?