Skip to content

Addon Engine

Addons are small JavaScript scripts that run inside Ghost’s proxy pipeline, giving you programmable control over every HTTP request and response. Think of them as “plugins” — you can write a script that adds a header to every request, blocks tracking domains, tags API calls by content type, or validates response formats. Each addon runs in its own isolated sandbox, so a buggy script can’t crash Ghost or affect other addons.

Ghost uses goja, a pure-Go JavaScript engine (no C compiler or external runtime needed), to execute addon code. Each addon gets its own VM (virtual machine) instance with strict security boundaries — no filesystem access, no network access, no require() imports. Addons interact with traffic through a controlled ghost.* API that Ghost provides.

What this diagram shows — how addons execute within the proxy pipeline:

When an HTTP request or response passes through Ghost’s proxy pipeline, the AddonInterceptor (stage 3 of 5 in the pipeline) delegates to the Addon Manager. The manager runs each enabled addon in priority order — lower numbers run first (priority 1 before priority 5 before priority 10). Each addon has its own isolated goja Runtime VM. Inside each VM, the addon accesses traffic through the ghost.* API — registering handlers via ghost.onRequest()/ghost.onResponse(), logging with ghost.log(), and storing state with ghost.store. If any addon drops the request or sends a custom response, the remaining addons are skipped — the first action wins.

When an addon is loaded (at startup or when created via the API):

  1. Create a fresh goja VMgoja.New() creates an isolated JavaScript environment
  2. Set stack limitvm.SetMaxCallStackSize(512) prevents infinitely recursive scripts from consuming all memory. 512 call frames is generous for normal code but catches runaway recursion.
  3. Remove dangerous globals — three built-in JavaScript features are set to undefined:
    • require — prevents loading external modules
    • process — prevents access to system information (Node.js convention)
    • globalThis — prevents accessing the global scope directly (could bypass sandbox restrictions)
  4. Install the ghost.* API — all Ghost-provided functions and properties are registered on a ghost namespace object
  5. Compile addon codegoja.Compile(name, code, true) compiles the JavaScript with strict mode enabled (the true parameter). Strict mode catches common mistakes like using undeclared variables.
  6. Run the compiled program — this executes the top-level code, which typically calls ghost.onRequest(fn) and/or ghost.onResponse(fn) to register handlers
  7. Store in engine — the running addon is added to the engine’s map, and the priority-sorted execution order is rebuilt

When an addon is updated (code changed, priority changed, enabled/disabled):

  1. Unload the old VM — remove from the addons map
  2. Load a fresh VM — if the addon is enabled, create a new VM with the updated code
  3. Rebuild execution order — the priority-sorted order is updated atomically

No proxy restart needed. The next HTTP request will use the updated addon. Note that hot-reload clears the addon’s in-memory store — any state accumulated by ghost.store.set() is lost.

LoadAll() loads all enabled addons from the database at Ghost startup. Errors are logged per-addon but don’t prevent other addons from loading — a syntax error in one addon won’t break the others.

LimitValueWhat Happens When Exceeded
Call stack512 framesScript is terminated with a stack overflow error. The addon remains loaded for future flows.
Execution timeout5 secondsThe VM is interrupted via vm.Interrupt() (called from a time.AfterFunc goroutine). The addon is logged as timed out and continues to be available for future flows.
Store keys1,024 per addonNew keys are silently ignored and a warning is emitted to the log channel. Existing keys can still be overwritten.
Blocked APIsrequire, process, globalThisSet to undefined. Attempting to call them throws a TypeError.
Log buffer256 entriesBuffered channel capacity. When full, the oldest entry is dropped to make room for the new one (non-blocking send).

The goja VM doesn’t have fetch(), XMLHttpRequest, fs, or any other I/O capabilities. The only way addons interact with the outside world is through the ghost.* API and the flow object passed to handlers. This is an inherent property of the goja engine (it’s a pure JavaScript interpreter, not a Node.js environment), so there’s nothing to “block” — these APIs simply don’t exist.

Every handler execution is wrapped in defer recover(). If a script causes a Go-level panic (stack overflow, interrupt timeout, goja runtime error), the panic is caught, logged, and emitted to the log channel. The addon remains loaded and available for future flows. Panics never propagate to the proxy — a buggy addon cannot crash Ghost.

When a flow enters the addon interceptor, the engine runs handlers in priority order:

  1. Snapshot — take a read-locked copy of the running addons list (copy the slice of pointers, not the map). This prevents data races if addons are added/removed while handlers are executing.
  2. Iterate in priority order (lower number = higher priority). For each addon:
    • Skip if the addon doesn’t have a handler for this phase (onReq is nil for ProcessRequest, onRes is nil for ProcessResponse)
    • Call execHandler() for the addon
  3. Short-circuit — if any handler returns ActionDrop or ActionRespond, stop executing further addons. The first action wins.
  1. Acquire VM lockra.vmMu.Lock(). The goja VM is NOT thread-safe, so every access must be serialized. This means addons cannot run truly in parallel — each addon’s handler blocks until the previous one finishes.
  2. Set up panic recoverydefer recover() catches any panics
  3. Build flow object — construct the JavaScript object with request/response properties and mutation methods (see Flow Object below)
  4. Set timeouttime.AfterFunc(5*time.Second, func() { vm.Interrupt("addon execution timeout (5s)") }) schedules an interrupt. If the handler runs longer than 5 seconds, the VM is forcibly stopped.
  5. Call the handler — invoke the registered JavaScript function with the flow object as its argument
  6. Check for actions — after the handler returns, check the flow object’s __action field:
    • "drop" → return ActionDrop (silently discard this request/response)
    • "respond" → return ActionRespond (send a custom response without contacting the upstream server)
    • anything else → return nil (continue to next addon)

The ghost object is the only API surface available to addon scripts. Everything an addon needs — registration, logging, state — goes through this namespace.

PropertyTypeDescription
ghost.namestringThe addon’s display name (e.g., “Request Logger”)
ghost.idstringThe addon’s unique ULID identifier
MethodDescription
ghost.onRequest(fn)Register a function that runs on every HTTP request. The function receives a flow object as its argument.
ghost.onResponse(fn)Register a function that runs on every HTTP response. The function receives a flow object with both request and response data.

An addon can register one or both handlers. If neither is registered, the addon does nothing.

MethodLog LevelExample
ghost.log(msg)Infoghost.log("Matched API call: " + flow.request.url)
ghost.warn(msg)Warningghost.warn("Large payload: " + flow.response.body.length)
ghost.error(msg)Errorghost.error("Unexpected status: " + flow.response.status_code)

Log messages are sent through a buffered channel (capacity 256) and broadcast as addon.log WebSocket events. A background goroutine reads from this channel and forwards entries to the WebSocket hub, so the frontend addon console can show log output in real time.

Each log entry contains: addon_id, addon_name, level ("info" / "warn" / "error"), message, and timestamp.

Each addon has its own private key-value store that persists across handler invocations. This lets addons track state — counting requests, remembering seen values, building statistics.

MethodDescription
ghost.store.get(key)Retrieve a value by key. Returns undefined if the key doesn’t exist.
ghost.store.set(key, val)Store a value. Values can be strings, numbers, booleans, arrays, or objects. Capped at 1,024 keys per addon — if the store already has 1,024 keys and you try to add a new one, it’s silently ignored and a warning is logged. Existing keys can always be overwritten regardless of the cap.
ghost.store.delete(key)Remove a key from the store.
ghost.store.keys()Returns an array of all key names.

Important: The store is in-memory only — it is NOT persisted to disk. It survives across handler invocations (request 1 sets a value, request 100 can read it) but is cleared when the addon is reloaded (code update, enable/disable toggle). If you need data to survive across reloads, use flow.tag() or flow.annotate() to store data on individual flows.

The flow object passed to addon handlers provides access to HTTP request and response data, plus methods to modify the traffic. All property names use snake_case (underscores, not camelCase).

PropertyTypeDescription
flow.idstringUnique ULID identifier for this flow
flow.session_idstringThe session this flow belongs to
flow.sourcestringHow this flow was captured: "proxy", "replay", "script", or "import"
PropertyTypeDescription
flow.request.methodstringHTTP method: "GET", "POST", "PUT", etc.
flow.request.urlstringFull request URL
flow.request.hoststringTarget hostname (e.g., "api.example.com")
flow.request.pathstringURL path (e.g., "/api/v1/users")
flow.request.protostringHTTP protocol version (e.g., "HTTP/1.1", "HTTP/2.0")
flow.request.headersobjectRequest headers as a flat key-value object (first value per key)
flow.request.content_typestringContent-Type header value
flow.request.bodystringRequest body as a string
MethodDescription
flow.request.setHeader(name, value)Add or replace a request header. Modifies the actual Go http.Header — the change is sent to the upstream server.
flow.request.removeHeader(name)Remove a request header entirely.
flow.request.setBody(text)Replace the request body with the given string.

Response Properties (onResponse handlers only)

Section titled “Response Properties (onResponse handlers only)”
PropertyTypeDescription
flow.response.status_codenumberHTTP status code (200, 404, 500, etc.). Note: this is status_code with underscore, not statusCode.
flow.response.status_textstringStatus text (e.g., "OK", "Not Found")
flow.response.headersobjectResponse headers as a flat key-value object
flow.response.content_typestringResponse Content-Type header value
flow.response.bodystringResponse body as a string
MethodDescription
flow.response.setHeader(name, value)Add or replace a response header. The change is sent to the client.
flow.response.removeHeader(name)Remove a response header entirely.
flow.response.setBody(text)Replace the response body with the given string.
MethodPhaseDescription
flow.tag(name)BothAdd a tag to the flow. Tags are searchable via tag:name in GQL. Thread-safe and deduplicated.
flow.removeTag(name)BothRemove a tag from the flow.
flow.annotate(key, value)BothAdd a metadata key-value pair to the flow’s metadata JSON.
flow.drop()BothDrop this flow — the request won’t be sent to the server (onRequest) or the response won’t be sent to the client (onResponse). No error is sent to the client; the connection is simply closed.
flow.respond(statusCode, headers, body)onRequest onlySend a custom response without contacting the upstream server. headers is a {key: value} object. Only works in onRequest — calling it in onResponse is a no-op (the response already came from the server).

When request or response data is nil (which can happen in edge cases), all properties return safe defaults: empty strings for text, 0 for numbers, empty objects for headers. All mutation methods become no-ops. This prevents “Cannot read property of undefined” errors in addon scripts.

Ghost provides 27 ready-to-use addon templates across 5 categories, available in the UI when creating a new addon:

CategoryCountTemplates
Logging4Request Logger, JSON API Monitor, Redirect Tracker, Cookie Inspector
Tagging4Auto-Tag by Content Type, API Error Highlighter, GraphQL Operation Tagger, Large Payload Flagger
Security10Block Tracker Domains, Add Security Headers, Strip Fingerprint Headers, Sensitive Data Scanner, CORS Misconfiguration Detector, Missing Security Headers, SQL Injection Pattern Detector, Verbose Error Detector, Open Redirect Detector, Mixed Content Detector
Testing6Mock API Endpoint, CORS Unblocker, Auth Token Injector, Contract Validator, Performance Budget, Response Consistency Checker
Modification3Response Body Rewriter, Request Header Injector, Latency Simulator

Templates are complete, working scripts — click one and it’s ready to run. You can modify the code after creating from a template.

The addon engine runs as the 3rd interceptor in Ghost’s 5-stage proxy pipeline:

Breakpoints → Map Rules → Addons → Storage → Security

What this means for addon authors:

  • Addons see traffic after breakpoint edits and URL rewrites — if you manually modified a request in a breakpoint, the addon sees the modified version
  • Addon modifications (tags, headers, body changes) are visible to the storage and security interceptors — tags you add will be stored and searchable
  • Addons can drop requests before they reach the upstream server — the storage interceptor never sees them
  • Addons can respond directly without contacting upstream — the client gets the addon’s response immediately

The interceptor interface method names are OnRequest and OnResponse (not ProcessRequest/ProcessResponse). The interceptor returns nil for continue (let the next interceptor run), or an *Action pointer with ActionDrop or ActionRespond to short-circuit the pipeline.

EventWhenPayload
addon.createdNew addon created via APIFull AddonDTO
addon.updatedAddon updated (code, priority, enabled)Full AddonDTO
addon.deletedAddon deleted via API{"id": "<addon-id>"}
addon.logAddon calls ghost.log(), ghost.warn(), or ghost.error(){addon_id, addon_name, level, message, timestamp}
MethodEndpointDescription
GET/api/v1/addonsList all addons, ordered by priority ASC then created_at ASC
POST/api/v1/addonsCreate a new addon. Name required. Defaults: enabled=true, priority=0. Auto-loads into engine if enabled.
GET/api/v1/addons/{id}Get a single addon by ID
PUT/api/v1/addons/{id}Update an addon. All fields optional (partial update via pointer types). Triggers hot-reload.
DELETE/api/v1/addons/{id}Delete an addon. Unloads from engine + deletes from database. Returns 204.
CREATE TABLE addons (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1, -- 0=disabled, 1=enabled
priority INTEGER NOT NULL DEFAULT 0, -- lower = runs first
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_addons_priority ON addons(priority);

The enabled field is stored as an integer (SQLite doesn’t have a boolean type) and converted to/from Go bool at the store layer.