Skip to Content
Agents & CLIPatterns

Patterns

Patterns every service and interface follows.

Adapter rule

MCP tool handlers, REST handlers, CLI scripts, and webhook handlers are adapters — they translate inputs, call a service method, and return the result. No business logic in them.

// ✅ correct server.registerTool('add_block_to_page', schema, async (input) => PageService.addBlock({ pageId: input.page_id, block: input.block, position: input.position, tenantId: input.tenant_id, }) ) // ❌ wrong — same logic will end up duplicated in REST and CLI server.registerTool('add_block_to_page', schema, async (input) => { const payload = await getPayload({ config }) const page = await payload.findByID({ collection: 'pages', id: input.page_id }) // ... } )

Tenant scoping

The service layer takes tenantId as an explicit argument. It does not resolve “which tenants can this user access?” — that belongs to the Payload RBAC layer.

// ✅ await PageService.addBlock({ pageId, block, tenantId, user }) // ❌ — service should not resolve tenant from user await PageService.addBlock({ pageId, block, user }) // user.tenants[0]?

Local API access control

When calling Payload Local API with a user, always set overrideAccess: false:

// ✅ enforces user permissions await payload.find({ collection: 'pages', user, overrideAccess: false }) // ❌ access control bypassed — runs with admin privileges await payload.find({ collection: 'pages', user })

For intentional admin-level operations (scripts, seed data), omit user entirely — overrideAccess defaults to true with no user.

Draft / publish

Agents create content as drafts unless explicitly told to publish:

// Safe default for agent-generated content await payload.create({ collection: 'pages', data: { title: 'Doctor Landing Page', layout: [...] }, draft: true, }) // Explicit publish await payload.update({ collection: 'pages', id: pageId, data: { _status: 'published' }, })

Context flags in hooks

When a service performs an operation that should skip normal hook side-effects (e.g. an agent batch update that shouldn’t fire individual notifications):

await payload.update({ collection: 'pages', id: pageId, data: { ... }, context: { agentAction: true, skipNotification: true }, req, }) // In the hook afterChange: [ async ({ doc, req, context }) => { if (context.skipNotification) return await sendNotification(doc) } ]

Transaction safety in hooks

Always pass req to nested Payload operations inside hooks. Missing req breaks atomicity:

// ✅ atomic — same transaction afterChange: [async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req }) }] // ❌ data corruption risk — separate transaction afterChange: [async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id } }) // ↑ missing req }]

Hook loop prevention

Operations inside hooks can re-trigger the same hook. Use a context flag:

afterChange: [ async ({ doc, req, context }) => { if (context.skipHooks) return await req.payload.update({ collection: 'posts', id: doc.id, data: { processedAt: new Date() }, context: { skipHooks: true }, req, }) } ]

Two operating modes

Human-on-demand (Claude Code / Cursor)

"Build me a landing page for an AI image generator that sells credit packs" "Add a pricing section using our existing Stripe products" "Create a new tenant for client Acme with their domain and branding"

Autonomous agent (scheduled / event-driven)

Every Monday → generate + publish weekly blog post from trending topics On lead signup → update lead status, trigger welcome email sequence On Stripe event → price changed? update pricing block copy automatically Nightly → check for expired promos, flag or auto-fix

Both modes call the same service layer. The scheduling/trigger infrastructure (cron, webhooks, MCP) is just an adapter on top.