Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/davila7-pocketbase-hooks/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 142% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 193% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 146% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 204% | 0% |
pb_hooks/*.pb.js (must end with .pb.js)import/export), no async/await, no arrow functions in older versions. Use function(){} and CommonJS require().__hooks — absolute path to the pb_hooks directorypb_data/types.d.ts (auto-generated, useful for IDE support)--hooksPool=25 flag controls concurrent JS goroutines (default: 25)jsrouterAdd("GET", "/api/hello/{name}", function(e) { var name = e.request.pathValue("name") return e.json(200, { "message": "Hello " + name }) }, /* optional middleware */)
{name} — named path parameter{path...} — wildcard (matches rest of path){$} — exact match (no trailing slash)| 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) |
js// 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()
jsrouterAdd("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 )
jsrouterUse(function(e) { // runs before every request console.log(e.request.method, e.request.url.path) return e.next() // MUST call e.next() to continue })
jsfunction 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.
Each record event has 3 variants:
onRecord*Execute — wraps the default action. Call e.next() to proceed.onRecord*AfterSuccess — runs after successful executiononRecord*AfterError — runs after execution errorjs// 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")
| 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 |
jsonRecordAuthWithPasswordRequest(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")
jsonRealtimeConnectRequest(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() })
jsonFileDownloadRequest(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() })
jsonRecordValidate(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")
jsonRecordEnrich(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")
jsvar 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
| Method | Returns | |--------|---------| | .all(results) | Populates array | | .one(result) | Single record | | .execute() | For INSERT/UPDATE/DELETE |
js$app.db().newQuery("SELECT * FROM posts WHERE status = {:status}") .bind({ status: "active" }) .all(results)
Always use named params {:param} — never concatenate SQL strings.
js$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)
js$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) })
js// 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" }))
jsvar 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
jsvar record = $app.findRecordById("posts", "RECORD_ID") record.set("title", "Updated Title") $app.save(record)
jsvar record = $app.findRecordById("posts", "RECORD_ID") $app.delete(record)
jsrecord.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
js$app.expandRecord(record, ["author", "tags"], null) var author = record.expandedOne("author") // single relation var tags = record.expandedAll("tags") // multi relation
js// 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)
jscronAdd("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
jsvar 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)
jsonMailerRecordVerificationSend(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
jsvar 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().
jsthrow 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")
| 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 |
jsvar token = $security.randomString(32) var hash = $security.hs256("data", "secret") var encrypted = $security.encrypt("data", "encryptionKey") var decrypted = $security.decrypt(encrypted, "encryptionKey")
jsvar result = $os.exec("ls", ["-la", "/tmp"]) // returns { code, output } var files = $os.readDir("/path") var tmp = $os.tempDir("prefix")
jsonRecordCreateExecute(function(e) { if (e.auth) { e.record.set("author", e.auth.id) } return e.next() }, "posts")
jsonRecordDeleteExecute(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")
jsrouterAdd("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())
jsonRecordCreateAfterSuccessExecute(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) } })
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 15,537 | 11,955 | -23% | 1 | 1 | 0% | 3,057 | 7,383 | +142% | 0 | 0 | — |
case-02 | fail→pass | 11,987 | 9,424 | -21% | 1 | 1 | 0% | 2,268 | 6,652 | +193% | 0 | 0 | — |
case-03 | fail→pass | 14,068 | 10,734 | -24% | 1 | 1 | 0% | 2,844 | 6,996 | +146% | 0 | 0 | — |
case-04 | pass→pass | 14,856 | 12,476 | -16% | 1 | 1 | 0% | 1,974 | 7,362 | +273% | 0 | 0 | — |
case-05 | pass→pass | 13,358 | 9,942 | -26% | 1 | 1 | 0% | 3,052 | 7,018 | +130% | 0 | 0 | — |
case-06 | pass→pass | 10,285 | 7,823 | -24% | 1 | 1 | 0% | 1,993 | 6,329 | +218% | 0 | 0 | — |
case-07 | fail→pass | 15,223 | 10,118 | -34% | 1 | 1 | 0% | 2,860 | 6,953 | +143% | 0 | 0 | — |
case-08 | fail→pass | 10,602 | 4,308 | -59% | 1 | 1 | 0% | 1,859 | 5,655 | +204% | 0 | 0 | — |
case-21 | fail→pass | 10,509 | 6,897 | -34% | 1 | 1 | 0% | 2,196 | 6,415 | +192% | 0 | 0 | — |
case-09 | fail→pass | 12,717 | 6,094 | -52% | 1 | 1 | 0% | 2,728 | 6,122 | +124% | 0 | 0 | — |
case-10 | fail→pass | 10,990 | 5,630 | -49% | 1 | 1 | 0% | 2,285 | 6,101 | +167% | 0 | 0 | — |
case-11 | pass→pass | 12,305 | 6,689 | -46% | 1 | 1 | 0% | 2,502 | 6,090 | +143% | 0 | 0 | — |
case-12 | pass→pass | 7,769 | 3,742 | -52% | 1 | 1 | 0% | 1,590 | 5,546 | +249% | 0 | 0 | — |
case-13 | pass→pass | 12,117 | 6,973 | -42% | 1 | 1 | 0% | 1,810 | 6,165 | +241% | 0 | 0 | — |
case-14 | pass→pass | 9,655 | 12,639 | +31% | 1 | 1 | 0% | 1,838 | 6,179 | +236% | 0 | 0 | — |
case-15 | fail→pass | 10,338 | 6,300 | -39% | 1 | 1 | 0% | 2,117 | 6,188 | +192% | 0 | 0 | — |
case-16 | pass→pass | 4,577 | 4,597 | +0% | 1 | 1 | 0% | 796 | 5,757 | +623% | 0 | 0 | — |
case-17 | pass→pass | 8,498 | 5,556 | -35% | 1 | 1 | 0% | 1,768 | 6,036 | +241% | 0 | 0 | — |
case-18 | fail→pass | 11,307 | 6,456 | -43% | 1 | 1 | 0% | 2,188 | 6,355 | +190% | 0 | 0 | — |
case-19 | fail→pass | 10,934 | 5,960 | -45% | 1 | 1 | 0% | 2,291 | 6,168 | +169% | 0 | 0 | — |
case-20 | fail→pass | 11,436 | 5,002 | -56% | 1 | 1 | 0% | 1,850 | 5,844 | +216% | 0 | 0 | — |
case-22 | fail→pass | 11,792 | 8,902 | -25% | 1 | 1 | 0% | 2,313 | 6,615 | +186% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +59 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.