New to Claude Skills? Learn how to install them →

Pdavila7 on GitHub

PocketBase Hooks

Free

Extend PocketBase with custom server-side logic.

Get this skill

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 source

1. Install with the skills CLI

npx skills add davila7/claude-code-templates/pb-hooks --agent claude-code

2. 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 davila7

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. Use function(){} and CommonJS require().
  • 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=25 flag 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

MethodUsage
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. Call e.next() to proceed.
  • onRecord*AfterSuccess — runs after successful execution
  • onRecord*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

HookEvent object fields
onRecordCreateExecutee.record
onRecordUpdateExecutee.record
onRecordDeleteExecutee.record
onRecordAfterCreateSuccesse.record — after successful create
onRecordAfterUpdateSuccesse.record — after successful update
onRecordAfterDeleteSuccesse.record — after successful delete
onRecordAfterCreateErrore.record, e.error — after failed create
onRecordAfterUpdateErrore.record, e.error — after failed update
onRecordAfterDeleteErrore.record, e.error — after failed delete
onRecordValidatee.record — add custom validation errors
onRecordEnriche.record — modify API response (hide/add fields)
onRecordsListRequeste.records, e.result — modify list response
onRecordRequestCreatee.record — during API create request
onRecordRequestUpdatee.record — during API update request
onRecordRequestDeletee.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

MethodReturns
.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

Email

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

ObjectPurpose
$appMain app instance — DB, records, collections, settings
$apisAPI middleware helpers
$securityJWT, encryption, random string generation
$osOS operations: $os.exec(), $os.readDir(), $os.tempDir()
$httpHTTP client
$filesystemFile helpers (fileFromPath, fileFromBytes, fileFromURL)
$dbxSQL 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