prxy.monster API-key BYOK is live. Start free

Lifecycle hooks

A module can implement up to four hooks. Each fires at a specific point in the request lifecycle.

init(storage)

Fires: once per process, at gateway startup.

Parameters: the resolved StorageAdapter. Use it to seed state, run a migration check, warm a local cache.

Returns: Promise<void>.

Failures: if init throws, the gateway logs the error and does not load the module for the rest of the process. Other modules continue.

async init(storage) {
  await storage.db.raw(`
    CREATE TABLE IF NOT EXISTS my_module_state (
      id TEXT PRIMARY KEY,
      value TEXT NOT NULL
    )
  `);
}

The cloud and local DB syntax differ. Either restrict raw() SQL to a portable subset, or branch on storage.kind.

pre(ctx)

Fires: before the provider call, in pipeline order. The first module’s pre runs first.

Parameters: RequestContext — request (mutable), metadata, storage, apiKey, logger, startTime.

Returns: Promise<PreResult>:

Failures: an uncaught throw is logged and the module is skipped — pipeline continues as if the module returned { continue: true }. The metadata key module-name.preFailed is set so downstream modules can react.

async pre(ctx) {
  try {
    const cached = await ctx.storage.kv.get(cacheKey(ctx.request));
    if (cached) {
      return {
        continue: false,
        response: JSON.parse(cached) as CanonicalResponse,
      };
    }
    return { continue: true };
  } catch (err) {
    ctx.logger.warn({ err }, 'cache lookup failed');
    return { continue: true };  // never deny the user a response
  }
}

stream(chunk, ctx)

Fires: for each chunk during a streaming response, in pipeline order.

Parameters:

Returns: Promise<CanonicalChunk> — the chunk to forward to the next module / client.

Failures: an uncaught throw is logged and the chunk is forwarded unmodified to the next module.

Stream-hook coverage is limited. mcp-optimizer and cost-guard use it; semantic-cache accumulates chunks for its post-write but doesn’t transform them. Treat this hook as partial until the compatibility matrix says otherwise.

post(ctx)

Fires: after the response is sent to the client. Fire-and-forget — does not block the client.

Parameters: ResponseContext — full request + response + duration.

Returns: Promise<void>. The return value is ignored.

Failures: caught and logged. The client never sees a post-hook error.

async post(ctx) {
  // Fire-and-forget: write to a slow analytics backend without blocking
  await ctx.storage.db.from('events').insert({
    user_id: ctx.apiKey.userId,
    model: ctx.request.model,
    duration_ms: ctx.durationMs,
    input_tokens: ctx.response.usage.inputTokens,
    output_tokens: ctx.response.usage.outputTokens,
  });
}

Post hooks are skipped on live provider streaming responses today. Pre hooks (including cache short-circuits which replay as synthetic streams) still fire. Post-hooks on streamed provider responses are planned.

Order of execution

For a non-streaming request through [A, B, C]:

A.init()  ──── (only at boot)
B.init()
C.init()

A.pre() ──▶ B.pre() ──▶ C.pre() ──▶ provider ──▶ A.post() ──▶ B.post() ──▶ C.post()
                                                  (fire-and-forget; client already returned)

Short-circuit at B (returns { continue: false, response }):

A.pre() ──▶ B.pre() (returns response) ──▶ A.post() ──▶ B.post() ──▶ C.post()
                                            ^^^^^^^^^ post still runs on the cached response

C’s pre is skipped. C’s post still runs (it’s a side effect — caches need to know the request happened).

Cross-module communication

Use ctx.metadata (a Map<string, unknown>) to pass values between modules:

// In your "estimator" module
async pre(ctx) {
  const cost = estimateRequestCost(ctx.request);
  ctx.metadata.set('estimated_cost', cost);
  return { continue: true };
}
 
// In a downstream "router" module
async pre(ctx) {
  const cost = ctx.metadata.get('estimated_cost') as number | undefined;
  if (cost && cost > 0.10) ctx.request.model = 'claude-haiku-4-5';
  return { continue: true };
}

Convention: namespace your keys with the module name (my-module.thing) to avoid collisions.

See also