.agents/skills/add-admin-api-endpoint/reference.md
The API framework is a pipeline-based system that processes HTTP requests through a series of stages before executing the controller logic. It provides consistent validation, serialization, and permission handling across all API endpoints.
Each request goes through these stages in order:
include to withRelated)The Frame class holds all request information and is passed through each stage. Each stage can modify it by reference.
{
original: Object, // Original input (for debugging)
options: Object, // Query params, URL params, context, custom options
data: Object, // Request body, or query/URL params if configured via `data`
user: Object, // Logged in user object
file: Object, // Single uploaded file
files: Array, // Multiple uploaded files
apiType: String, // 'content' or 'admin'
docName: String, // Endpoint name (e.g., 'posts')
method: String, // Method name (e.g., 'browse', 'read', 'add', 'edit')
response: Object // Set by output serialization
}
{
original: {
include: 'tags,authors'
},
options: {
withRelated: ['tags', 'authors'],
context: { user: '123' }
},
data: {
posts: [{ title: 'My Post' }]
}
}
Controllers are objects with a docName property and method configurations.
module.exports = {
docName: 'posts', // Required: endpoint name
browse: {
headers: {},
options: [],
data: [],
validation: {},
permissions: true,
query(frame) {}
},
read: { /* ... */ },
add: { /* ... */ },
edit: { /* ... */ },
destroy: { /* ... */ }
};
headers (Object)Configure HTTP response headers.
headers: {
// Invalidate cache after mutation
cacheInvalidate: true,
// Or with specific path
cacheInvalidate: { value: '/posts/*' },
// File disposition for downloads
disposition: {
type: 'csv', // 'csv', 'json', 'yaml', or 'file'
value: 'export.csv' // Can also be a function
},
// Location header (auto-generated for 'add' methods)
location: false // Disable auto-generation
}
options (Array)Allowed query/URL parameters that go into frame.options.
options: ['include', 'filter', 'page', 'limit', 'order']
Can also be a function:
options: (frame) => {
return frame.apiType === 'content'
? ['include']
: ['include', 'filter'];
}
data (Array)Parameters that go into frame.data instead of frame.options. Useful for READ requests where the model expects findOne(data, options).
data: ['id', 'slug', 'email']
validation (Object | Function)Configure input validation. The framework validates against global validators automatically.
validation: {
options: {
include: {
required: true,
values: ['tags', 'authors', 'tiers']
},
filter: {
required: false
}
},
data: {
slug: {
required: true,
values: ['specific-slug'] // Restrict to specific values
}
}
}
Global validators (automatically applied when parameters are present):
id - Must match /^[a-f\d]{24}$|^1$|me/ipage - Must be a numberlimit - Must be a number or 'all'uuid - Must be a valid UUIDslug - Must be a valid slugemail - Must be a valid emailorder - Must match /^[a-z0-9_,. ]+$/iFor custom validation, use a function:
validation(frame) {
if (!frame.data.posts[0].title) {
return Promise.reject(new errors.ValidationError({
message: 'Title is required'
}));
}
}
permissions (Boolean | Object | Function)Required field - you must always specify permissions to avoid security holes.
// Use default permission handling
permissions: true,
// Skip permission checking (use sparingly!)
permissions: false,
// With configuration
permissions: {
// Attributes that require elevated permissions
unsafeAttrs: ['status', 'authors'],
// Run code before permission check
before(frame) {
// Modify frame or do pre-checks
},
// Specify which resource type to check against
docName: 'posts',
// Specify different method for permission check
method: 'browse'
}
// Custom permission handling
permissions: async function(frame) {
const hasAccess = await checkCustomAccess(frame);
if (!hasAccess) {
return Promise.reject(new errors.NoPermissionError());
}
}
query (Function) - RequiredThe main business logic. Returns the API response.
query(frame) {
// Access validated options
const { include, filter, page, limit } = frame.options;
// Access request body
const postData = frame.data.posts[0];
// Access context
const userId = frame.options.context.user;
// Return model response
return models.Post.findPage(frame.options);
}
statusCode (Number | Function)Set the HTTP status code. Defaults to 200.
// Fixed status code
statusCode: 201,
// Dynamic based on result
statusCode: (result) => {
return result.posts.length ? 200 : 204;
}
response (Object)Configure response format.
response: {
format: 'plain' // Send as plain text instead of JSON
}
cache (Object)Enable endpoint-level caching.
cache: {
async get(cacheKey, fallback) {
const cached = await redis.get(cacheKey);
return cached || await fallback();
},
async set(cacheKey, response) {
await redis.set(cacheKey, response, 'EX', 3600);
}
}
generateCacheKeyData (Function)Customize cache key generation.
generateCacheKeyData(frame) {
// Default uses frame.options
return {
...frame.options,
customKey: 'value'
};
}
browse: {
headers: {
cacheInvalidate: false
},
options: [
'include',
'filter',
'fields',
'formats',
'page',
'limit',
'order'
],
validation: {
options: {
include: {
values: ['tags', 'authors', 'tiers']
},
formats: {
values: ['html', 'plaintext', 'mobiledoc']
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
read: {
headers: {
cacheInvalidate: false
},
options: ['include', 'fields', 'formats'],
data: ['id', 'slug'],
validation: {
options: {
include: {
values: ['tags', 'authors']
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
add: {
headers: {
cacheInvalidate: true
},
options: ['include'],
validation: {
options: {
include: {
values: ['tags', 'authors']
}
},
data: {
title: { required: true }
}
},
permissions: {
unsafeAttrs: ['status', 'authors']
},
statusCode: 201,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
edit: {
headers: {
cacheInvalidate: true
},
options: ['include', 'id'],
validation: {
options: {
include: {
values: ['tags', 'authors']
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: ['status', 'authors']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
destroy: {
headers: {
cacheInvalidate: true
},
options: ['id'],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
statusCode: 204,
query(frame) {
return models.Post.destroy(frame.options);
}
}
uploadImage: {
headers: {
cacheInvalidate: false
},
permissions: {
method: 'add'
},
query(frame) {
// Access uploaded file
const file = frame.file;
return imageService.upload({
path: file.path,
name: file.name,
type: file.type
});
}
}
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
return `members.${new Date().toISOString()}.csv`;
}
}
},
options: ['filter'],
permissions: true,
response: {
format: 'plain'
},
query(frame) {
return membersService.export(frame.options);
}
}
Wrap controllers for Express routes:
const {http} = require('@tryghost/api-framework');
// In routes
router.get('/posts', http(api.posts.browse));
router.get('/posts/:id', http(api.posts.read));
router.post('/posts', http(api.posts.add));
router.put('/posts/:id', http(api.posts.edit));
router.delete('/posts/:id', http(api.posts.destroy));
Call controllers programmatically:
// With data and options
const result = await api.posts.add(
{ posts: [{ title: 'New Post' }] }, // data
{ context: { user: userId } } // options
);
// Options only
const posts = await api.posts.browse({
filter: 'status:published',
include: 'tags',
context: { user: userId }
});
Create endpoint-specific validators in the API utils:
// In api/utils/validators/input/posts.js
module.exports = {
add(apiConfig, frame) {
// Custom validation for posts.add
const post = frame.data.posts[0];
if (post.status === 'published' && !post.title) {
return Promise.reject(new errors.ValidationError({
message: 'Published posts must have a title'
}));
}
}
};
Create input/output serializers:
// Input serializer
module.exports = {
all(apiConfig, frame) {
// Transform include to withRelated
if (frame.options.include) {
frame.options.withRelated = frame.options.include.split(',');
}
}
};
// Output serializer
module.exports = {
posts: {
browse(response, apiConfig, frame) {
// Transform model response to API response
frame.response = {
posts: response.data.map(post => serializePost(post)),
meta: {
pagination: response.meta.pagination
}
};
}
}
};
query(frame) {
const isAdmin = frame.options.context.user;
const isIntegration = frame.options.context.integration;
const isMember = frame.options.context.member;
if (isAdmin) {
return models.Post.findPage(frame.options);
} else {
frame.options.filter = 'status:published';
return models.Post.findPage(frame.options);
}
}
For streaming or special responses:
query(frame) {
// Return a function to handle Express response
return function handler(req, res, next) {
const stream = generateStream();
stream.pipe(res);
};
}
query(frame) {
// Set headers from within query
frame.setHeader('X-Custom-Header', 'value');
return models.Post.findPage(frame.options);
}
Use @tryghost/errors for consistent error responses:
const errors = require('@tryghost/errors');
query(frame) {
if (!frame.data.posts[0].title) {
throw new errors.ValidationError({
message: 'Title is required'
});
}
if (notFound) {
throw new errors.NotFoundError({
message: 'Post not found'
});
}
if (noAccess) {
throw new errors.NoPermissionError({
message: 'You do not have permission to access this resource'
});
}
}
permissions - Never omit this field, it's a security requirementoptions to whitelist params - Only allowed params are passed throughcacheInvalidate appropriately - True for mutations, false for readsunsafeAttrs for sensitive fields - Requires elevated permissions to modifyquery - Let serializers handle transformationdata for READ endpoints - When the model expects findOne(data, options)