A plugin is a single TypeScript file. Three things:
- Create
src/server/plugins/<name>/index.tsexporting aPlugin. - Push it into
ALL_PLUGINSinsrc/server/plugins/index.ts. npm run build && restart agent_api.
The loader handles detection, opt-out convar, status logging, and the list_plugins snapshot. You don't touch any other file.
Skeleton
import { z } from 'zod';
import { err, ok } from '../../util/envelope';
import { Plugin } from '../types';
import { isResourceStarted, safeExport } from '../helpers';
import { csvSet, isAllowed, listCallable, safeSerialize } from '../dynamic';
const RESOURCE = 'qb-core'; // the resource you depend on
type QbCore = Record<string, unknown> & {
Functions: { GetPlayer: (src: number) => unknown };
};
let cached: QbCore | null = null;
function getQb(): QbCore | null {
if (cached) return cached;
const obj = safeExport<() => QbCore>(RESOURCE, 'GetCoreObject');
if (obj) cached = obj();
return cached;
}
export const qbcorePlugin: Plugin = {
name: 'qbcore',
description: 'QBCore framework — players, jobs, money, items.',
detect: () => {
const started = isResourceStarted(RESOURCE);
if (!started.ok) return started;
if (!safeExport(RESOURCE, 'GetCoreObject')) {
return { ok: false, reason: `${RESOURCE} missing GetCoreObject export` };
}
return { ok: true };
},
install: ({ register, convars }) => {
register({
name: 'qbcore_get_player',
description: 'Fetch one QBCore player snapshot.',
input: z.object({ serverId: z.number().int().min(1) }).strict(),
handler: async (input: { serverId: number }) => {
const qb = getQb();
if (!qb) return err('INTERNAL', 'QBCore not loaded.');
const p = qb.Functions.GetPlayer(input.serverId);
if (!p) return err('PLAYER_NOT_FOUND', `serverId ${input.serverId} not found.`);
return ok(safeSerialize(p));
},
});
// Reflective dispatcher: expose every method on the player object.
const blocklist = csvSet('agent_api_plugin_qbcore_blocked_methods');
register({
name: 'qbcore_call_player',
description: 'Reflective call on a QBCore player object.',
input: z
.object({
serverId: z.number().int().min(1),
method: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_.]*$/),
args: z.array(z.unknown()).optional(),
})
.strict(),
handler: async (input) => {
const qb = getQb();
if (!qb) return err('INTERNAL', 'QBCore not loaded.');
const player = qb.Functions.GetPlayer(input.serverId) as Record<string, unknown> | null;
if (!player) return err('PLAYER_NOT_FOUND', `serverId ${input.serverId} not found.`);
const guard = isAllowed(input.method.split('.').at(-1)!, {
readonly: convars.readonly,
blocklist,
});
if (!guard.ok) return err('COMMAND_NOT_ALLOWED', guard.reason);
// resolve dotted path
let target: unknown = player;
const parts = input.method.split('.');
for (let i = 0; i < parts.length - 1; i++) {
target = (target as Record<string, unknown>)[parts[i]!];
}
const fn = (target as Record<string, unknown>)[parts.at(-1)!];
if (typeof fn !== 'function') {
return err('INVALID_INPUT', `${input.method} is not callable.`);
}
const raw = await Promise.resolve(
(fn as Function).apply(target, input.args ?? []),
);
return ok({ method: input.method, result: safeSerialize(raw) });
},
});
void listCallable; // optional, if you add a `_list_methods` tool
},
};Register it
// src/server/plugins/index.ts
import { qbcorePlugin } from './qbcore';
export const ALL_PLUGINS: Plugin[] = [
esxPlugin,
oxLibPlugin,
oxMysqlPlugin,
qbcorePlugin, // ← here
];Naming conventions
- Plugin
nameis also the convar prefix:agent_api_plugin_<name>_enabled. - Tool names start with
<plugin>_so the catalog stays grouped. - Mutating tools must respect
convars.readonlyand any per-plugin blocklist convar. - Add a
<plugin>_list_methodstool whenever you ship a reflective dispatcher — the agent needs discovery, not just dispatch.
Test it
npm run buildrestart agent_api- Look for
[agent_api] plugin enabled : qbcorein console. - Call
list_pluginsto confirm tool names. - Add probes to
scripts/smoke.mjs.