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.
Architecture
Section titled “Architecture”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.
VM Lifecycle
Section titled “VM Lifecycle”Loading
Section titled “Loading”When an addon is loaded (at startup or when created via the API):
- Create a fresh goja VM —
goja.New()creates an isolated JavaScript environment - Set stack limit —
vm.SetMaxCallStackSize(512)prevents infinitely recursive scripts from consuming all memory. 512 call frames is generous for normal code but catches runaway recursion. - Remove dangerous globals — three built-in JavaScript features are set to
undefined:require— prevents loading external modulesprocess— prevents access to system information (Node.js convention)globalThis— prevents accessing the global scope directly (could bypass sandbox restrictions)
- Install the
ghost.*API — all Ghost-provided functions and properties are registered on aghostnamespace object - Compile addon code —
goja.Compile(name, code, true)compiles the JavaScript with strict mode enabled (thetrueparameter). Strict mode catches common mistakes like using undeclared variables. - Run the compiled program — this executes the top-level code, which typically calls
ghost.onRequest(fn)and/orghost.onResponse(fn)to register handlers - Store in engine — the running addon is added to the engine’s map, and the priority-sorted execution order is rebuilt
Hot-Reload
Section titled “Hot-Reload”When an addon is updated (code changed, priority changed, enabled/disabled):
- Unload the old VM — remove from the addons map
- Load a fresh VM — if the addon is enabled, create a new VM with the updated code
- 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.
Startup
Section titled “Startup”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.
Sandbox Limits
Section titled “Sandbox Limits”| Limit | Value | What Happens When Exceeded |
|---|---|---|
| Call stack | 512 frames | Script is terminated with a stack overflow error. The addon remains loaded for future flows. |
| Execution timeout | 5 seconds | The 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 keys | 1,024 per addon | New keys are silently ignored and a warning is emitted to the log channel. Existing keys can still be overwritten. |
| Blocked APIs | require, process, globalThis | Set to undefined. Attempting to call them throws a TypeError. |
| Log buffer | 256 entries | Buffered channel capacity. When full, the oldest entry is dropped to make room for the new one (non-blocking send). |
No Built-In Network or Filesystem
Section titled “No Built-In Network or Filesystem”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.
Panic Recovery
Section titled “Panic Recovery”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.
Handler Execution
Section titled “Handler Execution”When a flow enters the addon interceptor, the engine runs handlers in priority order:
ProcessRequest / ProcessResponse
Section titled “ProcessRequest / ProcessResponse”- 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.
- Iterate in priority order (lower number = higher priority). For each addon:
- Skip if the addon doesn’t have a handler for this phase (
onReqis nil for ProcessRequest,onResis nil for ProcessResponse) - Call
execHandler()for the addon
- Skip if the addon doesn’t have a handler for this phase (
- Short-circuit — if any handler returns
ActionDroporActionRespond, stop executing further addons. The first action wins.
execHandler (per-addon)
Section titled “execHandler (per-addon)”- Acquire VM lock —
ra.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. - Set up panic recovery —
defer recover()catches any panics - Build flow object — construct the JavaScript object with request/response properties and mutation methods (see Flow Object below)
- Set timeout —
time.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. - Call the handler — invoke the registered JavaScript function with the flow object as its argument
- Check for actions — after the handler returns, check the flow object’s
__actionfield:"drop"→ returnActionDrop(silently discard this request/response)"respond"→ returnActionRespond(send a custom response without contacting the upstream server)- anything else → return
nil(continue to next addon)
ghost.* API
Section titled “ghost.* API”The ghost object is the only API surface available to addon scripts. Everything an addon needs — registration, logging, state — goes through this namespace.
Identity
Section titled “Identity”| Property | Type | Description |
|---|---|---|
ghost.name | string | The addon’s display name (e.g., “Request Logger”) |
ghost.id | string | The addon’s unique ULID identifier |
Handler Registration
Section titled “Handler Registration”| Method | Description |
|---|---|
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.
Logging
Section titled “Logging”| Method | Log Level | Example |
|---|---|---|
ghost.log(msg) | Info | ghost.log("Matched API call: " + flow.request.url) |
ghost.warn(msg) | Warning | ghost.warn("Large payload: " + flow.response.body.length) |
ghost.error(msg) | Error | ghost.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.
In-Memory Store
Section titled “In-Memory Store”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.
| Method | Description |
|---|---|
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.
Flow Object in JavaScript
Section titled “Flow Object in JavaScript”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).
Flow Identity
Section titled “Flow Identity”| Property | Type | Description |
|---|---|---|
flow.id | string | Unique ULID identifier for this flow |
flow.session_id | string | The session this flow belongs to |
flow.source | string | How this flow was captured: "proxy", "replay", "script", or "import" |
Request Properties (always available)
Section titled “Request Properties (always available)”| Property | Type | Description |
|---|---|---|
flow.request.method | string | HTTP method: "GET", "POST", "PUT", etc. |
flow.request.url | string | Full request URL |
flow.request.host | string | Target hostname (e.g., "api.example.com") |
flow.request.path | string | URL path (e.g., "/api/v1/users") |
flow.request.proto | string | HTTP protocol version (e.g., "HTTP/1.1", "HTTP/2.0") |
flow.request.headers | object | Request headers as a flat key-value object (first value per key) |
flow.request.content_type | string | Content-Type header value |
flow.request.body | string | Request body as a string |
Request Mutation Methods
Section titled “Request Mutation Methods”| Method | Description |
|---|---|
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)”| Property | Type | Description |
|---|---|---|
flow.response.status_code | number | HTTP status code (200, 404, 500, etc.). Note: this is status_code with underscore, not statusCode. |
flow.response.status_text | string | Status text (e.g., "OK", "Not Found") |
flow.response.headers | object | Response headers as a flat key-value object |
flow.response.content_type | string | Response Content-Type header value |
flow.response.body | string | Response body as a string |
Response Mutation Methods
Section titled “Response Mutation Methods”| Method | Description |
|---|---|
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. |
Flow Action Methods
Section titled “Flow Action Methods”| Method | Phase | Description |
|---|---|---|
flow.tag(name) | Both | Add a tag to the flow. Tags are searchable via tag:name in GQL. Thread-safe and deduplicated. |
flow.removeTag(name) | Both | Remove a tag from the flow. |
flow.annotate(key, value) | Both | Add a metadata key-value pair to the flow’s metadata JSON. |
flow.drop() | Both | Drop 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 only | Send 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). |
Safe Nil Defaults
Section titled “Safe Nil Defaults”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.
Templates
Section titled “Templates”Ghost provides 27 ready-to-use addon templates across 5 categories, available in the UI when creating a new addon:
| Category | Count | Templates |
|---|---|---|
| Logging | 4 | Request Logger, JSON API Monitor, Redirect Tracker, Cookie Inspector |
| Tagging | 4 | Auto-Tag by Content Type, API Error Highlighter, GraphQL Operation Tagger, Large Payload Flagger |
| Security | 10 | Block 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 |
| Testing | 6 | Mock API Endpoint, CORS Unblocker, Auth Token Injector, Contract Validator, Performance Budget, Response Consistency Checker |
| Modification | 3 | Response 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.
Pipeline Position
Section titled “Pipeline Position”The addon engine runs as the 3rd interceptor in Ghost’s 5-stage proxy pipeline:
Breakpoints → Map Rules → Addons → Storage → SecurityWhat 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.
WebSocket Events
Section titled “WebSocket Events”| Event | When | Payload |
|---|---|---|
addon.created | New addon created via API | Full AddonDTO |
addon.updated | Addon updated (code, priority, enabled) | Full AddonDTO |
addon.deleted | Addon deleted via API | {"id": "<addon-id>"} |
addon.log | Addon calls ghost.log(), ghost.warn(), or ghost.error() | {addon_id, addon_name, level, message, timestamp} |
API Reference
Section titled “API Reference”| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/addons | List all addons, ordered by priority ASC then created_at ASC |
POST | /api/v1/addons | Create 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. |
Database Schema
Section titled “Database Schema”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.