Skip to content

Plugins

Plugins let you extend t-req with custom functionality. Add dynamic variable resolvers, intercept requests and responses, register CLI commands, or inject server middleware.

  • Custom resolvers{{$timestamp()}}, {{$env(API_KEY)}}, {{$hmacSign(payload)}}
  • Lifecycle hooks — Transform requests, retry on failure, log responses
  • CLI commands — Add treq mycommand subcommands
  • Server middleware — Inject authentication, logging, or custom routes into treq serve
  • Tools — Define typed tools with Zod schemas for AI/agent workflows
treq-plugin-timing.ts
import { definePlugin } from '@t-req/core';
export default definePlugin({
name: 'timing',
version: '1.0.0',
hooks: {
async 'response.after'(input, output) {
console.log(`${input.request.method} ${input.request.url} - ${input.timing.total}ms`);
}
}
});

Add to your config:

treq.jsonc
{
"plugins": [
"file://./treq-plugin-timing.ts"
]
}

For inline assertions directly in .http files, install and load @t-req/plugin-assert:

treq.jsonc
{
"plugins": ["@t-req/plugin-assert"]
}

Then use @assert directives:

# @assert status == 200
# @assert jsonpath $.token exists
GET {{baseUrl}}/auth/login
Accept: application/json

treq run exits with code 1 if any assertion fails (or is malformed), making this a zero-test-runner CI path.

Plugins are configured in treq.jsonc (or legacy treq.config.ts):

{
"plugins": [
// NPM package
"@acme/treq-plugin-auth",
// NPM package with options
["@acme/treq-plugin-retry", { "maxRetries": 3 }],
// Local file (file:// URL)
"file://./plugins/my-plugin.ts",
// Subprocess plugin (any language)
{
"command": ["python3", "./plugins/hmac-signer.py"],
"config": { "algorithm": "sha256" },
"timeoutMs": 5000
}
],
// Optional: restrict plugin permissions
"pluginPermissions": {
"default": ["env"],
"@acme/treq-plugin-auth": ["secrets", "network"]
}
}
SourceFormatExample
NPM package"package-name""@acme/treq-plugin-auth"
NPM with options["package-name", options]["@acme/retry", { "max": 3 }]
Local file"file://path""file://./plugins/my-plugin.ts"
Subprocess{ command: [...] }{ "command": ["python3", "plugin.py"] }
Inline (TS only)Plugin objectmyPlugin({ verbose: true })

Plugins declare required permissions. Users can restrict what plugins can access:

PermissionGrants access to
secretsSecret managers (Vault, AWS SSM)
networkOutbound HTTP requests
filesystemRead/write files outside project
envProcess environment variables
subprocessSpawn child processes
enterpriseEnterprise context (org, user, session)
definePlugin({
name: 'my-plugin',
permissions: ['env', 'network'], // Declare what you need
async setup(ctx) {
// ctx.env is only available if 'env' permission granted
const apiKey = ctx.env?.API_KEY;
// ctx.fetch is only available if 'network' permission granted
await ctx.fetch?.('https://example.com/register');
}
});

Hooks let you intercept and modify the request lifecycle:

parse.after → request.before → request.compiled → request.after → fetch
↑ ↓
└─────────── retry signal ◄────────────── response.after
error

Called after parsing a .http file. Modify the AST before execution.

hooks: {
'parse.after'(input, output) {
// Add a header to all requests in the file
for (const req of output.file.requests) {
req.headers['X-Parsed-By'] = 'my-plugin';
}
}
}

Input: { file: ParsedHttpFile, path: string } Output: { file: ParsedHttpFile } (mutable)

Called before variable interpolation. Add headers, modify URL, or skip the request.

hooks: {
'request.before'(input, output) {
// Add auth header
output.request.headers['Authorization'] = `Bearer ${input.variables.token}`;
// Skip requests to certain domains
if (input.request.url.includes('internal.corp')) {
output.skip = true;
}
}
}

Input: { request, variables, ctx } Output: { request, skip? } (mutable)

Called after interpolation, before fetch. Final chance to modify — ideal for signing.

hooks: {
async 'request.compiled'(input, output) {
// Sign the request (all variables already interpolated)
const signature = await sign(output.request.body);
output.request.headers['X-Signature'] = signature;
}
}

Input: { request: CompiledRequest, variables, ctx } Output: { request } (mutable)

Called immediately before fetch. Read-only observation for logging, metrics, audit.

hooks: {
'request.after'(input) {
console.log(`${input.request.method} ${input.request.url}`);
}
}

Input: { request: CompiledRequest, ctx } Output: None (read-only)

Called after receiving a response. Process, log, or signal retry.

hooks: {
async 'response.after'(input, output) {
// Log response
console.log(`${input.response.status} (${input.timing.total}ms)`);
// Retry on rate limit
if (input.response.status === 429) {
const retryAfter = input.response.headers.get('Retry-After');
output.retry = {
delayMs: retryAfter ? parseInt(retryAfter) * 1000 : 1000,
reason: 'Rate limited'
};
}
}
}

Input: { request, response, timing, ctx } Output: { status?, statusText?, headers?, body?, retry? }

Called when a request fails (network error, timeout). Handle or signal retry.

hooks: {
error(input, output) {
if (input.error.code === 'ECONNRESET' && input.ctx.retries < input.ctx.maxRetries) {
output.retry = { delayMs: 1000, reason: 'Connection reset' };
}
}
}

Input: { request, error, ctx } Output: { error, retry?, suppress? }

Resolvers provide dynamic values in {{$name(args)}} syntax. Names must start with $.

definePlugin({
name: 'my-resolvers',
resolvers: {
// Simple resolver
$timestamp: () => String(Date.now()),
// Resolver with arguments
$env: (key) => process.env[key] ?? '',
// Async resolver
$vault: async (path) => {
const secret = await fetchFromVault(path);
return secret;
},
// Multiple arguments (use JSON array syntax in .http files)
$hmac: (algorithm, secret, data) => {
return createHmac(algorithm, secret).update(data).digest('hex');
}
}
});

Usage in .http files:

GET {{baseUrl}}/api/data
X-Timestamp: {{$timestamp()}}
X-Api-Key: {{$env(API_KEY)}}
Authorization: {{$vault(secret/api-key)}}
X-Signature: {{$hmac(["sha256", "{{secret}}", "{{body}}"])}}

Write plugins in any language. Communicate over stdin/stdout using NDJSON.

  1. t-req spawns your process with the configured command
  2. Init handshake — t-req sends init, plugin responds with capabilities
  3. Requests/events — t-req sends resolver, hook, or event messages
  4. Shutdown — t-req sends shutdown, plugin exits gracefully

Init request:

{"id":"1","type":"init","protocolVersion":1,"config":{"key":"value"},"projectRoot":"/path"}

Init response:

{"id":"1","type":"response","result":{"name":"my-plugin","version":"1.0.0","protocolVersion":1,"capabilities":["resolvers","hooks"],"resolvers":["$env","$timestamp"],"hooks":["request.before"]}}

Resolver request:

{"id":"2","type":"resolver","name":"$env","args":["API_KEY"]}

Resolver response:

{"id":"2","type":"response","result":{"value":"secret123"}}

Hook request:

{"id":"3","type":"hook","name":"request.before","input":{...},"output":{...}}

Hook response:

{"id":"3","type":"response","result":{"output":{...}}}

Event (no response):

{"type":"event","event":{"type":"fetchStarted","method":"GET","url":"..."}}

Shutdown (no response):

{"type":"shutdown"}

See examples/plugins/treq_plugin_env.py for a complete Python subprocess plugin that provides $env, $timestamp, $uuid, and $randomInt resolvers.

#!/usr/bin/env python3
import json
import sys
import os
def main():
for line in sys.stdin:
msg = json.loads(line.strip())
msg_type = msg.get("type")
msg_id = msg.get("id")
if msg_type == "init":
response = {
"id": msg_id,
"type": "response",
"result": {
"name": "my-python-plugin",
"protocolVersion": 1,
"capabilities": ["resolvers"],
"resolvers": ["$env"]
}
}
elif msg_type == "resolver":
name = msg.get("name")
args = msg.get("args", [])
value = os.environ.get(args[0], "") if args else ""
response = {"id": msg_id, "type": "response", "result": {"value": value}}
elif msg_type == "shutdown":
break
else:
continue
print(json.dumps(response), flush=True)
if __name__ == "__main__":
main()

Register custom CLI commands:

definePlugin({
name: 'openapi-importer',
commands: {
'import-openapi': async (ctx) => {
const [specPath] = ctx.args;
const spec = JSON.parse(await ctx.readFile(specPath));
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
await ctx.writeHttpFile(`requests/${op.operationId}.http`, [{
name: op.summary,
method: method.toUpperCase(),
url: `{{baseUrl}}${path}`,
}]);
}
}
ctx.log(`Imported ${Object.keys(spec.paths).length} endpoints`);
}
}
});

Usage: treq import-openapi ./openapi.json

Inject middleware into treq serve:

definePlugin({
name: 'cors-plugin',
middleware: [
async (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method === 'OPTIONS') {
res.statusCode = 204;
return res.end();
}
await next();
}
]
});

Define typed tools for AI/agent workflows:

import { definePlugin, tool, z } from '@t-req/core';
definePlugin({
name: 'crypto-tools',
tools: {
hash: tool({
description: 'Hash a value with SHA-256',
args: {
value: z.string().describe('Value to hash'),
encoding: z.enum(['hex', 'base64']).default('hex'),
},
async execute(args) {
const hash = createHash('sha256').update(args.value).digest(args.encoding);
return hash;
}
})
}
});

Initialize resources on load, clean up on shutdown:

definePlugin({
name: 'db-plugin',
async setup(ctx) {
ctx.log.info('Connecting to database...');
this.db = await connectToDb(ctx.config.variables.dbUrl);
},
async teardown() {
await this.db?.close();
}
});
  1. Declare permissions — Only request what you need. Users can restrict plugins.

  2. Handle errors gracefully — Don’t crash the pipeline. Log and continue where possible.

  3. Use async sparingly — Hooks run in sequence. Keep them fast.

  4. Namespace resolvers — Use prefixes like $myPlugin_timestamp to avoid conflicts.

  5. Version your plugins — Helps users track compatibility.

  6. Document configuration — Explain what options your plugin accepts.

  7. Test with subprocess — If building a subprocess plugin, test the NDJSON protocol with echo '{"type":"init",...}' | your-plugin.

The repository includes example plugins demonstrating common patterns:

See the examples/plugins/ directory for the full source code.