PocketBase Hooks

SkillDatabases & data

PocketBase Hooks is a skill for adding server-side logic to PocketBase through its pb_hooks JavaScript files. It helps an agent write custom routes, event hooks, cron jobs, emails, HTTP requests, and database queries in the goja ES5 runtime. Use it when a PocketBase project needs behavior beyond what the built-in API provides.

Use PocketBase Hooks in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add PocketBase Hooks and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the PocketBase Hooks skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Have a PocketBase project with a pb_hooks directory available.

PocketBase HooksStart free

What your AI can do with it

  • Write custom HTTP routes and middleware in pb_hooks
  • Add event hooks that run before or after PocketBase operations
  • Create cron jobs for scheduled server-side tasks
  • Send emails from PocketBase server-side code
  • Make HTTP requests and query the PocketBase database

Getting started

  1. Have a PocketBase project with a pb_hooks directory available.
  2. Add or edit JavaScript files inside pb_hooks for the logic you need.
  3. Configure routes, hooks, or cron entries in those files using the goja ES5 runtime.
  4. Restart PocketBase so it loads the updated hook files.

What this skill tells your AI

The instructions your AI receives, as published by davila7/claude-code-templates in cli-tool/components/skills/pocketbase/pb-hooks/SKILL.md and read by ahel’s review.

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)
    }
})

Signals

GitHub stars
32k
Forks
4k
Last commit
Oct 2026

Questions

What runtime do PocketBase hooks use?
PocketBase hooks run in the goja ES5 JavaScript runtime, so the code must stay within ES5 syntax and the APIs PocketBase exposes.
Can I write custom routes with PocketBase Hooks?
Yes. The skill covers routing and middleware so you can define custom HTTP routes in pb_hooks.
Does it support cron jobs?
Yes. It covers cron jobs for scheduled server-side work in PocketBase.
Can hooks send email or make HTTP requests?
Yes. The skill covers sending emails and making HTTP requests from PocketBase server-side code.
Where do I put the hook files?
Hook files live in the pb_hooks directory of a PocketBase project.
Advanced
Item type
skill
Key
pocketbase-hooks
Source
github.com/davila7/claude-code-templates