Docs

Build with Port42 and your AI companions.

Port42 is a Mac desktop for your AI companions. Every surface on it is a port, a live thing with a face you can click and a stream you can read, write, and subscribe to. These docs cover the model, how AI companions work in it, and the API that you, your companions, and any other program all share.

Start here

What you need

  • A Mac on Apple Silicon with macOS 14 or later. Windows and iOS are coming soon.
  • Claude Code or Codex, installed and signed in. Setup finds them on your PATH, and offers to install one if neither is there.
  • Any other CLI or agent framework can join as an AI companion too. Claude Code and Codex are the two that work out of the box; integration for the rest is still deepening.
There are no API keys to paste. Port42 runs no model of its own, never calls a model provider, and never reads a provider credential. Each CLI uses your own Claude or ChatGPT account, logged in inside its own terminal.
The command line

The port42 command

Port42 installs a port42 command. In a terminal Port42 started, it calls as that session's own identity; anywhere else it calls as the port42 CLI, a client Port42 enrolled when it installed it. Every example in these docs works with it, or as a raw HTTP call to the same door. The door is plain HTTP because it listens only on 127.0.0.1, so a call never leaves your computer. Between machines, the same calls travel through the relay, encrypted end to end.

# who am I, and who can I @mention
port42 whoami

# call any method: key=value for strings, key:=json for numbers and objects, key=@file for a file
port42 chat.post '{"port":"<space or port id>","text":"@lead slower pulse?"}'
port42 chat.read port=<space or port id> limit:=10
port42 port.update id=<id> [email protected] token=<token>

# the reference and the manual, from the build you run
port42 help api
port42 help ports

# Port42's skills for Claude Code and Codex sessions Port42 did not start
port42 skills install

# bring this terminal's Claude Code session into a Port42 port
port42 teleport --space <name>

A value that starts with @ is read as a file (key=@file), so pass a message that starts with an @mention as one JSON object, as in the chat.post line above.

A refused call prints its {error, code, …} to stderr and exits 1, and a stale write carries current to retry with. --port <n> talks to another instance's gateway. Teleport also takes --session, --list, and --dry-run.

The model

Everything is a port

A port is a live surface on your desktop with one id and one contract. There are three types, all made the same way with port.create.

A map of every primitive, the method behind it, and more than 100 things they make: the elements of software.

TypeWhat it isMade with
webHTML, CSS and JS that you or a companion writes. The Port42 dark theme is injected for you.{type:"web", html}
terminalA native terminal running a shell or a CLI, such as Claude Code or Codex.{type:"terminal", command}
browserAn embedded browser with an address bar that follows links.{type:"browser", url}

Scopes are ports too. The desktop is port 0. A space is a port that holds ports. Every one of them, at every scope, has its own chat. Because the model is the same all the way down, a grant, an address, or a subscription works the same whether it points at one tile or a whole space.

Port scripts run as ES modules, so top-level await works and module declarations are not window globals. Attach handlers with addEventListener; inline onclick attributes cannot see your functions. The full port-authoring manual is served by the API itself: call help with {topic:"ports"}.

How ports got here

The phases

Ports grew in phases, one release at a time. The first five made ports what they are. Nautilus, which ships as Port42 v1, rebuilt everything around them.

Ports, v0.1 to v0.5
✓ Phase 1 · complete

Inline ports

Companions emitted ports in conversation, rendered inline with the bridge API.

Retired in v1. Port fences and inline ports are gone; a companion makes a port with port.create and it lands on the desktop.
✓ Phase 2 · complete

Pop out and dock

Ports detached onto the desktop as live tiles. Move, resize, park, and keep them across restarts.

Carried into v1 as arranging. A new port moves nothing, close archives, ⌘K brings it back.
✓ Phase 3 · complete

Generative ports

Ports called a model through the bridge with ai.complete.

Retired in v1. Port42 runs no model. A port that needs one asks an AI companion in its chat and subscribes for the reply.
✓ Phase 4 · complete

Device APIs

Terminal, clipboard, files, notifications, audio, camera, screen capture, a headless browser, and macOS automation.

Carried into v1, each gated per caller in Settings > Access.
✓ Phase 5 · complete

The Shell

Ports became the desktop. Zoom from every space to one focused port, over a living background.

Carried into v1 as the shell every surface lives in.
Nautilus, Port42 v1
✓ Phase 0 · done

The door

One door for every caller. The gateway carries calls and subscriptions and nothing else, and one connection is one named caller.

✓ Phase 1 · done

Remove, and build the chat port

The in-app model, the old messaging, and inline ports removed. Every space and every port gets its own chat, and the first run moves to Claude Code or Codex.

✓ Phase 2 · done

Arranging

A new port takes a free spot and moves nothing. Close archives and ⌘K reopens. Parking places a port exactly where you drop it.

✓ Phase 3 · done

The pipe

Ports feed ports. Hidden ports run with no tile, and AI companions watch ports and wake when they change.

✓ Phase 4 · in 1.0

The remote pipe

Share one web port with one invite, live on both machines, with one chat and AI companions waking on both sides. Built: remote callers see only their grant, the machine key and address, the relay with Noise IK, the per-port invite, a shared port as a live tile, one chat across machines, the Share box, the sharing pill, accept, fork, move, and the browser guest runtime and page.

Still to land: the invite page at tele.port42.ai, and the harness test.
✓ Phase 5 · done

Skills

Port42 ships knowledge, not a model. Five skills load in every Port42 terminal, generated from the same registry as the API.

Companions

Chat and AI companions

An AI companion is a CLI session running in a terminal port, such as Claude Code or Codex, known to Port42 by name. You talk to companions in chat, and every space and every port has one. An @mention in a chat wakes that companion, and it replies in the same chat.

# post to a chat: port 0 is the desktop, or pass a space id or a port id
curl -s http://127.0.0.1:4242/call -H "$AUTH" \
  -d '{"method":"chat.post","args":{"port":"<space or port id>","text":"@lead slower pulse?"}}'

# read it back, oldest first; pass after to get only what is newer
curl -s http://127.0.0.1:4242/call -H "$AUTH" \
  -d '{"method":"chat.read","args":{"port":"<space or port id>","limit":10}}'

Echo

When setup finishes, Port42 opens Echo, your first companion. Echo is a Claude Code or Codex session in a terminal port, briefed with Port42's welcome. Ask it for something and it builds a port.

/imagine

Type /imagine and one line in any chat, or press ⌘I for the quick imagine box. Port42 writes the brief and opens a new space with a small team in it: a lead that sets the direction and two engineers that build in rounds, in the port's chat, until the lead reports done. Follow along in the space's chat and the port's chat, and join in by @mentioning any of them. /imagine stop ends it early, and --versions N sets how many rounds it may take.

A website can open the imagine box with a line filled in through port42://imagine?line=…&from=…. It never starts a team by itself; you press Enter.

Making companions and watches

companions.create makes a companion the way the new-companion card does: a Claude Code or Codex CLI in a terminal port, or any command run headless, with its own name and system prompt. companions.watch points a companion at a port: an event of the kinds you name wakes it for a turn, and its reply lands in that port's chat. every sets the least time between two wakes.

port42 companions.create name=scribe agent=codex prompt="You keep the incident timeline."
port42 companions.watch port=<logs port> companion=scribe kinds:='["port"]' every:=60
port42 imagine.start line="an incident room for the checkout errors"

Bringing in your sessions

At first run, Port42 finds the Claude Code and Codex sessions already running on your computer and brings them in, grouped into a space per project. Each waits in its space as a companion. Echo tells you where they went.

Voice

Hold space and talk. Your words go to the chat or port in front of you, and dictation works in other apps too. The speech model runs on your computer and downloads the first time you use it. Settings has a Voice tab.

Presence

The chat that asked shows who has the message, who is working on it and for how long, and what it is doing right now ("is working: editing ShellView.swift"). A machine you share a port with sees only the kind of activity ("editing a file"). When a Claude turn fails, Port42 says why in that same chat. Ports and companions read the same thing with presence.list and the presence event.

Working directory

Give a space a working directory with space.setWorkingDirectory, and every companion started in that space begins there, each with its own session.

The door

Driving a port from a terminal

Everything goes through one door: POST /call on loopback, port 4242 by default. You clicking a port, a companion calling it, and the Port42 CLI all use the same methods with the same permissions.

Who you are

Every call names a caller. A terminal that Port42 started has $PORT42_TOKEN_FILE, the path to its own token, and $PORT42_CLIENT_ID, the name Port42 knows it by. Read the file at call time, since it is reissued when the app restarts. Any other program joins as a named client in Settings > Access, which gives it a token of its own. Never borrow another tool's token: the permission prompt would name that tool, and the grant would land on it.

A token on every write

Every write carries the port's token as it was when you composed the write. port.create, ports.list, and every write return one, so you thread it and never re-read a port just to write to it. A write with no token is refused with token_required. A write composed against an older state is refused with stale_write. Both carry current, so one retry with that token lands on top of the newer state. This is why several companions and a person can work one port without overwriting each other.

AUTH="Authorization: Bearer $(cat "$PORT42_TOKEN_FILE")"

# replies are shown unwrapped: over HTTP each arrives as {"content": "<the reply as a JSON string>"}; the port42 command unwraps it

# make a port
curl -s localhost:4242/call -H "$AUTH" -d '{"method":"port.create",
  "args":{"type":"web","html":"<title>glow</title><h1>0.60</h1>"}}'
→ {"id":"8E00…","token":"51222e11:2","title":"glow"}

# write with the token you hold
curl -s localhost:4242/call -H "$AUTH" -d '{"method":"port.update",
  "args":{"id":"8E00…","html":"<h1>0.30</h1>","token":"51222e11:2"}}'
→ {"ok":true,"token":"51222e11:3"}

# someone else wrote first: yours is refused, and told what is current
→ {"code":"stale_write","error":"This port has changed since you read it. Re-read it and retry.",
    "expected":"51222e11:3","current":"51222e11:4"}

# read the change, then retry once on the current token
curl -s localhost:4242/call -H "$AUTH" -d '{"method":"port.update",
  "args":{"id":"8E00…","html":"<h1>0.35</h1>","token":"51222e11:4"}}'
→ {"ok":true,"token":"51222e11:5"}

The write verbs

MethodWhat it does
port.updateReplace a web port's HTML.
port.patchSearch and replace inside a web port, for targeted fixes.
port.pushSend data into a web port, or raw keystrokes into a terminal.
port.execRun JavaScript in a web port's context and get the value back.
port.rename, port.move, port.manageTitle, position, and state (focus, close, dock, restore).
port.restoreRoll a port back to an earlier version from port.history.

To read without writing, use ports.list, port.getHtml, port.getDom, port.console (what a port printed, useful when a port you built throws), port.history, and port.position. Every port shows who is driving it, and every version records who made it.

Composition

Composing ports

A port is an actor. Input goes in with port.push, state comes out with port.publish, and anything that wants to watch uses port.subscribe. One port can feed the next with no code outside the ports.

// producer: inside a port, publish your own state
port42.port.publish('state', getState());

// consumer: inside another port. Do not await it; the promise is the stream.
const sub = port42.port.subscribe(producerId, e => {
  if (e.kind === 'port.state') render(e.payload);
});

A port's own kinds are namespaced: you publish state, subscribers see port.state, so a port can never pose as a system event. Each event is {topic, kind, payload, token}, and token is the port's state at that moment, so you can write straight back without a re-read.

System kinds that can arrive on a port's topic include chat, driver, console, push, presentation, state, terminal.output, browser.load, browser.error, browser.redirect, screen.frame, camera.frame, audio.data, audio.transcription, filedrop, and companion.activity.

Subscribing from outside Port42

Over the gateway, port.subscribe is WebSocket only. Connect to /ws, send it as a call envelope, and events arrive as stream frames on the same call id. On /call it is refused with unsupported, because /call answers once and the stream never ends.

The desktop

Arranging

  • A new port takes a free spot and moves nothing else. Restart, and everything is where you left it.
  • Closing a port archives it. ports.list with include_closed shows closed ports, and port.reopen brings one back with its id, content, position, and chat. A terminal relaunches its command in its last directory. In the app, ⌘K reopens any port.
  • Park a port in the rail to set it aside, exactly where you drop it.
  • Pin a port in its space, above the other tiles, or on every desktop in one position (port.manage with pin, pinEverywhere, unpin).
  • ⌘G goes to the galaxy of your spaces and back. ⌘K finds any space, port, or companion, including recently closed ports.
  • port.delete removes a closed port for good, with its versions and chat. An open port is refused, so nothing live is deleted in one step.
  • Spaces: space.create, space.list, space.current, space.switchTo.
Across machines

Sharing a port

Security status. Sharing in Port42 v1 (invites, the relay, and the browser guest) is new code and has not been independently audited. Connections between machines are encrypted end to end with the Noise protocol, the relay forwards bytes it cannot read, and a guest reaches only the port you invited them to. Those are design properties, not audited guarantees. Until an audit is done, treat a shared port like a screen share: don't share credentials, personal or customer data, or anything covered by regulation. Running your own relay keeps sharing traffic inside your network; it does not replace an audit.

Share one web port with one person on another machine. They get the same live port, never a copy. A change on either side shows up on the other, you and your AI companions on both machines drive it, and it has one chat. They reach that port and nothing else you have.

Share it

  • Open … on a web port of yours and choose Share…. The Share box makes the link and copies it in one click.
  • Choose what they can do. use lets them click, type, and post in the chat. edit lets them change the port itself. remote wake (on by default) lets their companions and yours wake each other in the port's chat. allow a copy lets them fork it. Seeing the port is what sharing is, so it is always on.
  • For a sensitive share, turn on a code. One click copies the link and the six-digit code together, and you send the code another way. Five wrong tries and the invite is dead.
  • The Share box lists what the port itself can do on your computer, such as use your clipboard or make web requests, because anyone you let in can make it do so.
  • One invite grants one port. The desktop (port 0) and spaces cannot be shared. A link works once and expires after seven days unless you set otherwise.

The sharing pill

A shared port carries a pill in its chrome. On your port it reads shared · 2 (or invite sent). Open it to see each machine it is shared with, switch their use, edit, and wake on or off, withdraw an invite nobody has used, stop sharing, or invite someone else. On someone else's port the pill says whose it is (Ada's, or Ada's · offline), what you can do, remote wake, and leave.

Accept it

Click the invite link, or paste it into ⌘K. The accept box shows whose port it is, what it lets you do, the code field when the invite needs one, and remote wake, on by default. Choose open it and the port appears as a tile on your desktop. Nothing is joined until you say so.

One port and one chat, on both machines

  • Your tile is a window onto their port. What the port is (its content, pushes, and chat) lives on their machine. What your window shows (its page, its console, code run in it) stays on yours.
  • A shared port has one chat, kept where the port lives. Your tile's chat shows it live and posts into it.
  • Every call from another machine says who there made it, so the driver chip and the chat name the person or companion, for example tide (gordon).
  • Each machine gets a name when it joins: the name set in Settings, or the person's name, with the start of its id added if that name is taken.
  • A stale write is refused with current across machines too, and one retry lands.
  • If their computer goes offline, your tile says so and reconnects on its own.

AI companions on both machines

With remote wake on, a mention in the shared chat wakes the companion it names on that person's machine, and its reply comes back to the one chat. Each side decides for its own companions: the sharer in the Share box and the person joining in the accept box, both on by default, and either can turn it off later from the tile's chrome. A wake runs in that machine's terminal, on that person's own model.

Tested live: one Claude Code companion on each of two computers built one WebGL shader together through the port's chat, handing off by mention four times, in under three minutes.

What can be shared

Web ports. A terminal or a browser port is refused, at invite and at accept. Someone using a shared terminal would be typing into your shell, and a browser port is signed in as you. Companions in terminals work together across machines through a shared port's chat instead.

Fork and move

  • Fork makes an independent copy. "Fork: a copy" under … copies your own web port. On someone else's port, "fork a copy" appears only when they allowed a copy.
  • Move sends a port to another space, or to another machine with an invite that hands it over once. When they open it, the port becomes theirs and closes on your computer (archived, so you can restore it).

No Port42? A browser works

The same invite opens in any browser, on a laptop or a phone. The invite page is a small guest-only Port42 at tele.port42.ai. It shows who shared what and offers Open in Port42, Open here, and Get Port42. After Join, the port runs in a sandboxed frame with its chat beside it. The page does nothing until you click, keeps its key in that browser so a refresh is the same guest, and never hands the key to the port's frame.

The page and its guest runtime are built. Its home at tele.port42.ai ships with v1.

How it travels

Sharing is peer to peer through a relay. Both machines connect outward over a secure WebSocket on port 443 to a relay, relay1.port42.ai by default, and run a Noise IK session end to end between their own keys. The relay pairs the two and forwards bytes it cannot read. It keeps nothing but who is connected, in memory, and neither side learns the other's IP address. Nothing on your computer listens beyond loopback, and no token crosses the internet. Each Port42 has one Ed25519 key in the Keychain, and its public key is the machine's part of a port's address, port42://<peer>/<port>. Direct connections are a later upgrade; today every connection goes through a relay. The architecture page has the diagram.

Self-hosting a relay

The relay is open source, one static Go binary with no database, in gateway/cmd/port42-relay. Run your own so sharing stays inside your network and never depends on Port42's servers. Traffic is end-to-end encrypted either way.

# from a clone of port42-native, build context gateway/
docker build -f gateway/relay.Dockerfile -t port42-relay gateway
docker run -p 8080:8080 port42-relay

# or without Docker
cd gateway && go build -o port42-relay ./cmd/port42-relay
PORT=8080 ./port42-relay
  • It listens on $PORT (default 8080) and serves /v1 for clients and /health. There are no other flags.
  • Put it behind TLS on a domain you control, so clients reach it as wss://relay.yourcompany.com/v1. It needs WebSockets and nothing else: no UDP, no database. Any port works; 443 gets through the most networks.
  • In Port42, open Settings > Remote, add the URL under Relays, and remove the default if you want only yours. Each relay shows whether this computer is connected to it.
  • New invites list your relays, so whoever accepts connects through them. Invites made before the change keep the relays they were made with.
  • The operator of a relay can see who connects to whom, when, and how much, never the content.

From the API

MethodWhat it does
invite.create(port, rights, expiresIn, requireCode)Makes an invite for one port. Returns {link, code?, id, expires, discloses}. Rights are any of see, use, edit, wake_agents, fork; the default is see, use, and wake_agents.
invite.accept(link, code, remoteWake)Joins someone else's port; it opens here as a tile. Returns {address, title, rights, tile}. remoteWake defaults to true.
invite.list()Your invites, and whether each is open, used (and by whom), expired, or withdrawn.
invite.revoke(id)Withdraws an invite nobody has used.

After accepting, call port methods by the port's address or the tile's id, with the same verbs you use locally. A remote caller may call port methods only, only on the ports it was granted, within its rights. Everything else is refused with not_granted.

Stop sharing

Withdraw an unused invite from the pill or with invite.revoke. To remove someone who already joined, stop sharing from the pill or remove them in Settings > Access. It takes effect on their next call, and nothing else they have changes.

Control

Permissions

Port42 asks the first time a caller uses a sensitive capability. A grant is per caller and per capability, and you can see and revoke every one in Settings > Access. A denial is never permanent; a later call asks again.

CapabilityCovers
terminalterminal.exec
screenscreenshots, window lists, screen streaming and recording
clipboardreading and writing the clipboard
filesystemfs.* outside what you picked
camera, microphonecamera frames, audio capture
automationAppleScript and JXA
browserheadless browser sessions
restoutbound HTTP with rest.call
notificationmacOS notifications
Beyond ports

Device APIs

The same door exposes the computer itself. Each call is gated by the permissions above.

NamespaceMethods
screencapture, displays, windows, stream, stopStream, record, record.start, record.stop, record.status
audiocapture, stopCapture, play, speak, stop
cameracapture, stream, stopStream
browseropen, navigate, text, html, execute, capture, close
clipboardread, write
fspick, read, write, list, mkdir
automationrunAppleScript, runJXA
terminalexec
notifysend
restcall, with a named secret from the secrets store, so the raw credential is never visible to the caller
storageget, set, list, delete, for state that survives remount and restart
companionslist, get
user, generaluser.get, whoami, presentation, help
When a call fails

Errors

Every error carries a code to branch on and a message written for a person. Branch on the code; messages change.

CodeWhat to do
token_required, stale_writeRetry once with current from the error.
missing_arg, bad_arg, unknown_method, js_syntaxFix the call.
not_found, no_surface, port_pausedThe target is missing, not ready, or paused.
wrong_stateChange state (stop a stream, close a session), then call again.
permission_denied, access_deniedAsk the person to grant the capability or pick the file.
lockedPort42 is locked, so nothing was asked. Call again once the person unlocks it.
permission_cancelledNobody answered: the permission card was withdrawn. Call again to ask again.
os_deniedmacOS refused it. The person allows Port42 in System Settings > Privacy & Security.
auth_required, auth_revokedEnroll in Settings > Access. A revoked credential never works again, so do not retry it.
js_timeout, timed_outAllow longer, or return a plain value instead of a promise that never resolves.
not_grantedYou are on another machine and your invite does not cover this. Ask the host for a new one.
invite_invalidThe invite is used, expired, withdrawn, or needs the right code. Ask for a new one.
no_host, host_offline, transport_failedThe call never reached Port42 (or the other machine). Retrying is always safe.
rate_limitedYou sent more than the gateway takes in a second, and the call was dropped. Slow down and send it again.
unsupportedThis computer cannot do it. Do not retry.
Everything else

API reference

Every method in Port42 1.0, generated from the live registry (87 methods). Click one for its arguments. The same reference as plain text is /llms.txt, and the port-authoring manual is /ports-context.txt. From a running Port42, port42 help api and port42 help ports print them for the build you run.

audio

audio.capture(options)microphone

Start microphone capture. Streams audio.transcription events (and audio.data when rawAudio is set) to the calling port until audio.stopCapture.

audio.play(data, options)

Play base64-encoded audio data (WAV, MP3, AAC).

audio.speak(text, options)

Speak text aloud using text-to-speech

  • rate number Speech rate 0.1-1.0 (default 0.5)
  • text string, required Text to speak
audio.stop()

Stop any active speech synthesis or audio playback.

audio.stopCapture()microphone

Stop the microphone capture and release the audio engine.

automation

automation.runAppleScript(source, timeout)automation

Execute AppleScript code and return the result. Use this to control other applications on macOS.

  • source string, required AppleScript source code
  • timeout integer Timeout in seconds (default: 30, max: 120)
automation.runJXA(source, timeout)automation

Execute JavaScript for Automation (JXA) code and return the result. Use this to control other applications on macOS.

  • source string, required JXA source code
  • timeout integer Timeout in seconds (default: 30, max: 120)

browser

browser.capture(sessionId, options)browser

Take a screenshot of an open browser session. Returns base64 PNG.

  • sessionId string, required Browser session ID from browser_open
browser.close(sessionId)browser

Close a browser session

  • sessionId string, required Browser session ID to close
browser.execute(sessionId, js)browser

Run JavaScript in an open browser session and return the result.

browser.html(sessionId, options)browser

Read the HTML of an open browser session, optionally scoped to a CSS selector.

  • options object { selector } to scope the read.
  • selector string CSS selector to read from (default: the whole page).
  • sessionId string, required The browser session.
browser.navigate(sessionId, url)browser

Navigate an open browser session to a new URL.

browser.open(url, options)browser

Open a URL in a headless browser and return the page title. Use browser_text to read page content after opening.

  • url string, required The URL to open (http or https)
browser.text(sessionId, options)browser

Extract text content from an open browser session

  • selector string CSS selector to extract from (default: body)
  • sessionId string, required Browser session ID from browser_open

camera

camera.capture(scale)camera

Capture a photo from the device camera. Returns a base64 PNG image.

camera.stopStream()

Stop the camera stream and release the capture session.

camera.stream(options)camera

Start continuous camera streaming. Pushes camera.frame events to the calling port until camera.stopStream.

chat

chat.post(port, text)

Post to a port's chat. Every port has one: pass port 0 for the desktop, a space id, or a port's id. The entry is attributed to you, the caller, and every subscriber of the port gets a `chat` event carrying it.

  • port string, required Whose chat: `0` (the desktop), a space id, or a port id / udid / title.
  • text string, required What to say.
chat.read(port, after, limit)

Read a port's chat, oldest first. Pass `after` (a seq you have seen) to get only what is newer. Returns { entries, last }, where `last` is the newest seq in the chat (0 when empty).

  • after integer Only entries with a seq greater than this.
  • limit integer At most this many, the newest ones (default 50, max 200).
  • port string, required Whose chat: `0` (the desktop), a space id, or a port id / udid / title.

clipboard

clipboard.read()clipboard

Read the current clipboard contents. Returns text or base64 image data.

clipboard.write(data)clipboard

Write text to the system clipboard

  • data string, required The text to copy to clipboard

companions

companions.create(name, agent, args, runs, port, kinds, cwd, prompt, command, space_id)terminal

Make a companion, as the new-companion card does: an agent CLI (claude or codex) in a terminal port, or a custom command run headless. runs: "port" (default, on the desktop) or "hidden" (no place on the desktop; reach it through its chat). It joins the space and hears @mentions there; pass `port` to have it watch that port instead, woken by `kinds` (default ["port"], the port's own events) and replying in its chat. Needs the terminal permission, since it starts one.

  • agent string The CLI (default claude).
  • args array Arguments for the CLI or command.
  • command string agent custom: the command to run.
  • cwd string Working directory (default: the space's).
  • kinds array With `port`: event kinds that wake it.
  • name string, required Its name; @mention it by this.
  • port string A port to watch instead of listening to the space.
  • prompt string Its system prompt.
  • runs string Where its terminal runs (default port).
  • space_id string The space (default: yours, else the current one).
companions.get(id)

Get details about a specific companion by ID

  • id string, required The companion's ID
companions.list(space_id, port)

List the companions in a space with their names, models, and trigger modes. Defaults to YOUR space, the companions you share this space with, because a companion acts within its space, not the whole instance. Pass space_id to target a different space, or space_id:"*" for the full global roster across every space in the Port42 instance (rarely what you want).

  • port string From another machine: the shared port whose space to list.
  • space_id string Omit for your own space (the default). A space id targets that space. "*" returns the whole-instance roster.
companions.unwatch(port, companion)

Stop watching a port. Call it as the watcher, or pass `companion`.

  • companion string Whose watch, by name or id (default: you).
  • port string, required The watched port (id, udid or title).
companions.watch(port, kinds, every, companion)

Watch a port: an event on it of a kind you name wakes you for a turn, and your reply is posted in that port's chat. Default kinds: ["port"], the port's own published events (port.publish). Others: "console" (a log line or error), "state" (an edit), "chat" (every post in its chat), or one exact kind such as "port.alert". Events that arrive while you are in a turn are held and given to you together when it ends. Watching again changes the watch and resumes it if it paused (a watch pauses after 60 wakes in an hour, and says so in the port's chat). Call it as the companion that should wake, or pass `companion` to set a watch for another.

  • companion string Whose watch, by name or id (default: you).
  • every integer The least time between two wakes, in seconds (default: none).
  • kinds array Event kinds that wake you (default ["port"]).
  • port string, required The port to watch (id, udid or title).
companions.watches(companion)

List watches: yours, another companion's (`companion`), or every one (`companion`: "*"). Each is {companion, port, title, kinds, every, paused}.

  • companion string A name or id, or "*" for all (default: you).

fs

fs.list(path)filesystem

List the contents of a directory in the Port42 data directory. Relative paths only (e.g. "scopes/strategy" or "scopes/strategy/decisions"). Returns a sorted list of filenames.

  • path string, required Relative path to the directory (e.g. "scopes/strategy")
fs.mkdir(path)filesystem

Create a directory (and any missing parent directories) in the Port42 data directory. Relative paths only (e.g. "scopes/strategy/decisions").

  • path string, required Relative path to create (e.g. "scopes/strategy/decisions")
fs.pick(options)

Open the native file picker. The chosen paths become readable and writable for the calling principal via fs.read / fs.write, with no further permission.

fs.read(path, encoding)filesystem

Read a file. Use a relative path (e.g. "scopes/strategy/scope.md") to read from the Port42 data directory without a file picker. Use an absolute path for picker-approved files.

  • encoding string utf8 (default) or base64
  • path string, required Relative path within Port42 data directory (e.g. "scopes/strategy/scope.md") or absolute path for picker-approved files.
fs.write(path, data, encoding)filesystem

Write a file. Use a relative path (e.g. "scopes/strategy/facts.md") to write to the Port42 data directory, parent directories are created automatically. Use an absolute path for picker-approved files.

  • data string, required Content to write
  • encoding string utf8 (default) or base64
  • path string, required Relative path within Port42 data directory (e.g. "scopes/strategy/facts.md") or absolute path for picker-approved files.

imagine

imagine.budget(space, versions)

Set the version budget of the imagine team in a space, for example to let it keep going after the budget is spent. The team's writes to its port past the budget are refused with budget_spent. The same as typing /imagine --versions N in that space's chat.

  • space string, required The space the team was imagined in.
  • versions integer, required The new budget, in versions of the port (at most 20).
imagine.start(line, versions)terminal

Start an imagine team: from one line, a new space with its port (a placeholder until v1) and a lead and two engineers (their terminals on its desktop) who build that port, in at most `versions` versions (default 10), until the lead posts DONE. Returns the space, the port, the team's names, the port title and the budget. The same as ⌘I or typing /imagine in a chat.

  • line string, required What to make, in the person's words.
  • versions integer The version budget (default 10, at most 20).

invite

invite.accept(link, code, remoteWake)

Accept an invite someone sent you: this instance joins their port, which opens here as a tile. Returns { address, title, rights, tile }. Then call methods on the port by its address or the tile's id. remoteWake (default true): a mention of one of your companions in that port's chat wakes it here, on your model; the tile's chrome can turn it off later.

  • code string The six-digit code, if the invite needs one.
  • link string, required The invite link (https://tele.port42.ai/#…).
  • remoteWake boolean Let their chat wake your companions for this port (default true).
invite.create(port, rights, expiresIn, requireCode)

Make an invite link that lets one person on another machine open ONE port: in Port42 if they have it, otherwise in their browser. Returns { link, code?, id, expires, discloses }. rights: any of see, use, edit, wake_agents, fork (default see, use and wake_agents: remote wake, their companions may wake yours in this port's chat; fork lets them take a copy, which Port42 offers only when given). requireCode: a six-digit code they must type, sent to them another way. `discloses` lists what the port itself can do on this machine; whoever you let in can make it do so. Port 0 and spaces cannot be shared.

  • expiresIn integer Seconds until the link stops working (default 7 days, at most 30).
  • port string, required The port to share (id / udid / title).
  • requireCode boolean Require a six-digit code, to send another way.
  • rights array see, use, edit, wake_agents, fork. Default see, use and wake_agents.
invite.list()

The invites this instance has made: id, port, rights, expiry, whether a code is required, and whether each is open, used (by which peer), expired or withdrawn.

invite.revoke(id)

Withdraw an invite that has not been used. To remove someone who already joined, remove them in Settings → Access.

notify

notify.send(title, body, options)notification

Send a macOS system notification

  • body string, required Notification body text
  • title string, required Notification title

port

port.close()

Close the calling port.

port.console(id, level, tail)

Check a port for problems. level=count returns only how many errors and warnings it has logged, no text: the cheap check that a port you built works. The default (problems) adds the errors and warnings themselves (the most recent 20), to deal with them. level=all reads everything it printed, for debugging. A terminal's output has no levels, so for a terminal the default is its last 50 lines. `omitted` says how many lines were left out.

  • id string, required The port's UDID (from ports_list), or a terminal's name.
  • level string count: the numbers only. problems (default for a web port): errors and warnings. all: every line, for debugging.
  • tail integer How many recent lines to return (default 20 for problems, 50 for all).
port.create(options)

Create a port and return its id. The uniform way to make any port. type:"web" needs html (a full port HTML body) and renders inline in chat. type:"terminal" needs command and opens a native terminal (runs in /bin/zsh; the command is typed in, claude/gemini get the Port42 hooks). type:"browser" needs url and opens an embedded browser tile with an address bar that follows links. type:"chat" reveals that space's chat port (idempotent, one chat per space; brings it back if parked, popped out or closed, and a DM is a space, so pass its space_id to open that conversation). For terminals you may also pass args, cwd, systemPrompt (companion personality), env, and initialInput (a line left waiting, unsent, in the CLI's input box). Drive the result with port_push (input to terminals, data to web ports) and list with ports_list. Pass space_id to target a space (default: current).

  • args array type:"terminal", arguments for the command.
  • command string type:"terminal", executable/CLI to run (e.g. "bash", "htop", "claude").
  • cwd string type:"terminal", working directory (default: home).
  • env object type:"terminal", custom environment variables for the shell.
  • html string type:"web", full port HTML body (include a <title> and <meta name="version">).
  • initialInput string type:"terminal", a line typed into the CLI once it is up but NOT submitted: it waits in the input box for the user to press Enter. For handing someone a first prompt to run. Use port_push instead to actually send input.
  • presentation string Where the port appears: "tiled" (default, a desktop tile), "parked" (a chip in the rail) or "hidden" (runs with no tile: a background job, a pipe stage, or an agent nobody needs to watch; show it with port.manage show).
  • space_id string Space to create the port in (default: current space).
  • systemPrompt string type:"terminal", companion personality/role appended to the CLI's system prompt.
  • title string Port title (default: derived from html <title>, or the command).
  • type string, required The port type to create.
  • url string type:"browser", the page to open (e.g. "https://example.com").
port.delete(id)

Delete a CLOSED port for good: its record, versions and chat. Close it first (port.manage close); an open port is refused, so nothing live is ever deleted in one step.

  • id string, required The closed port's id.
port.exec(id, js, token)

Execute JavaScript on a live port. Use this to call functions, push data, or update state on an existing port without replacing its HTML. The JS runs in the port's webview context with access to window, document, and any globals the port defines.

  • id string, required The port's UDID (from ports_list)
  • js string, required JavaScript code to execute in the port's context. Return a value to get it back in the response, as {value, token}. A bare expression yields its value (multi-line is fine). A multi-statement body needs an explicit return: `foo(); 42` is a syntax error, `foo(); return 42;` works.
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
port.getDom(id, selector)

Read a WEB or BROWSER port's LIVE DOM, what is on screen right now, including everything its JS has changed since load. Use this, not port_get_html, when you need current state: port_get_html returns the stored SOURCE, which does not reflect any port_exec or port_push that has run since. Returns {html, token}; pass that token as 'expect' on your next write and it will be refused rather than clobber someone if the port moved in between.

  • id string, required The port's UDID (from ports_list)
  • selector string Optional CSS selector to read just one subtree. Omit for the whole document.
port.getHtml(id, version)

Read the HTML of a port. Omit 'version' to get the current HTML. Pass 'version' (from port_history) to read a specific historical snapshot.

  • id string, required The port's UDID (from ports_list)
  • version integer Optional version number (from port_history). Omit for current HTML.
port.history(id)

List all saved versions of a port by its UDID. Returns version number, createdBy, and createdAt for each snapshot. Use port_get_html with a version number to read a specific snapshot, or port_restore to roll back.

  • id string, required The port's UDID (from ports_list)
port.info()

Return the calling port's own id, title, space, capabilities, and activity token.

port.manage(id, action, token)

Manage a port. Actions: focus (raise to the front of the desktop), close (archive it: it can be reopened with port.reopen), hide (off the desktop and out of the rail, still running, with its chat and subscriptions), show (bring a hidden port back onto its desktop), pin (keep it above the other ports in its space), pinEverywhere (show it in every space, above the other ports, at one position), unpin. Check the status field from ports_list: 'tiled' | 'parked' | 'hidden'.

  • action string, required One of: focus, close, hide, show, pin, pinEverywhere, unpin (minimize, dock, restore and undock are older names for hide and show)
  • id string, required The port's UDID or title
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
port.move(id, x, y, space_id, token)

Move a port's tile to specific desktop coordinates. Use screen_info to get display bounds first.

  • id string, required The port's UDID (from ports_list)
  • space_id string Which desktop to move it on. A port kept from another space is a tile on BOTH, with a position on each. Defaults to the current space when the port is on it, else the port's home space.
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
  • x number, required Horizontal position in desktop points
  • y number, required Vertical position in desktop points
port.patch(id, search, replace, token)

Make a targeted edit to a port's HTML, replace an exact string with new content. Much safer than port_update for small changes because only the specified text is replaced; everything else is preserved exactly. Use port_get_html first to read the current HTML, find the exact string to replace, then call port_patch. Errors if 'search' is not found in the current HTML, so the port is never silently mangled. Snapshots the result the same as port_update, and reaches the page the same way (reloading only when it has to; see port_update).

  • id string, required The port's UDID (from ports_list)
  • replace string, required The string to replace it with.
  • search string, required The exact string to find in the current HTML. Must match exactly, copy it from port_get_html output.
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
port.position(id, space_id)

Return a port's position and size on one desktop (a kept port has a position per desktop).

port.publish(kind, payload)

A port emits its OWN state or event on its own Notify topic, for consumers watching via port_subscribe. This is a port broadcasting AS itself, distinct from port_push, which is input sent INTO a port. Only meaningful from inside a port; the topic is the calling port's own id, so there is no target argument. Use this instead of having a consumer reach in with port_exec to read state: the port publishes, consumers subscribe.

  • kind string, required Event kind, e.g. 'state', 'progress', 'error'. Namespaced on the way out: you publish 'state', subscribers see 'port.state', so a port cannot emit a system event like 'driver' or 'browser.load'.
port.push(id, data, token)

Send input to a port, one verb, dispatched by the port's type. A WEB port receives the data as a 'port42:data' CustomEvent with the payload in event.detail. A TERMINAL port receives the data as raw keystrokes typed into the shell: end with a newline (e.g. "ls\n") to run the command, or omit it to leave the line waiting unsubmitted. Use the id from ports_list. Prefer this over port_exec for data transfer.

  • data required For web ports: any JSON value (object/array/string/number) delivered as event.detail. For terminal ports: a string of raw keystrokes (include \n to execute). Required, omitting it is refused with missing_arg rather than sent as nothing, and a terminal refuses an explicit null because there is no keystroke for null.
  • id string, required The port's UDID (from ports_list), or a terminal's name.
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
port.rename(id, title, token)

Rename a port. Sets the port's display title (shown in the title bar). Works for tiled, parked, docked, and inline ports. Use the port's id from ports_list.

  • id string, required The port's UDID (from ports_list)
  • title string, required The new title for the port
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
port.reopen(id)

Reopen a closed port with its id, content, position and chat. A terminal relaunches its command in its last working directory. Closed ports are listed by ports_list with include_closed.

  • id string, required The closed port's id.
port.restore(id, version, token)

Restore a port to a specific earlier version. The port's live HTML is replaced with the snapshot and a new version entry is recorded. Use port_history to find available version numbers.

  • id string, required The port's UDID (from ports_list)
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.
  • version integer, required The version number to restore to (from port_history)
port.setCapabilities(capabilities)

Set the calling port's own capabilities list.

port.setTitle(title)

Set the calling port's own title. Subscribe to a port's live event stream. Yields Notify events { topic, kind, payload, token } as the port emits them (e.g. terminal.output). `token` is the port's state token AT THAT MOMENT, so you can write next without re-reading the port first. OVER THE GATEWAY THIS IS WEBSOCKET-ONLY: connect to /ws and send it as a `call` envelope, and events arrive as `stream` frames on the same call_id. On HTTP /call it is refused with `unsupported`, because the stream never ends and a request/response call could only hang. The stream stays open until cancelled.

  • id string, required The port to observe (id / udid / title).
port.subscribe(id)streaming

Subscribe to a port's live event stream. Yields Notify events { topic, kind, payload, token } as the port emits them (e.g. terminal.output). `token` is the port's state token AT THAT MOMENT, so you can write next without re-reading the port first. OVER THE GATEWAY THIS IS WEBSOCKET-ONLY: connect to /ws and send it as a `call` envelope, and events arrive as `stream` frames on the same call_id. On HTTP /call it is refused with `unsupported`, because the stream never ends and a request/response call could only hang. The stream stays open until cancelled.

  • id string, required The port to observe (id / udid / title).
port.update(id, html, token)

Update an existing port's HTML content. The port can be identified by its UDID or title. Works whether the port is windowed or minimized. The page is reloaded only when it has to be: a change confined to <style> is applied in place, and any other change is first offered to the page as a cancelable 'port42:update' event (detail.html is the new HTML), which the page may apply itself by calling preventDefault(), keeping its state. Returns applied: unchanged, styles, handledByPage or reloaded.

  • html string, required The new HTML content for the port (full HTML, not a diff)
  • id string, required The port's UDID or title to identify which port to update
  • token string, required REQUIRED. The port's `token`, as it was when you composed this write, from ports_list, port_create, or whatever your last write returned. Without it the write is refused with 'token_required'; if the port has changed since, with 'stale_write'. Both carry the current token, so retry once with that instead of clobbering whoever moved it.

ports

ports.list(capabilities, space_id, include_closed)

List active ports. Each port has an id (UDID), title, capabilities array, status, spaceId, createdBy (an id) with createdByName (who that is, for display), and cwd (if it has a terminal). Terminal ports also report surfaceBound. Use capabilities: ["terminal"] to filter to terminal ports; pass space_id to list only that space's ports. Use the id field with port_push for reliable routing (raw keystrokes to terminals, data to web ports). Always show the id and capabilities fields when presenting results, they are required for follow-up tool calls.

  • capabilities array Filter to ports that have all of these capabilities. Examples: "terminal", "claude-code", "browser". Omit to list all ports.
  • include_closed boolean Also list closed (archived) ports, with status 'closed'. Reopen one with port.reopen.
  • space_id string List only this space's ports. Omit to list every space's.

presence

presence.list(port)

Who is on a chat's messages right now: each companion that has a message from this chat (`received`), is working on it (`working`), or is waiting for the person (`waiting`, with `why` when it said). `doing` says what it is doing right now ("editing ShellView.swift", "running swift test") when its CLI reports tools (Claude Code does); a caller on another machine is told only the kind ("editing a file"). Returns { presence: [{name, state, since, why?, doing?}] }, empty when nobody is. Subscribe to the port for the `presence` event to hear each change; the event carries only the kind of what each is doing.

  • port string, required Whose chat: a space id, or a port id / udid / title.

rest

rest.call(url, options)rest

Make an HTTP request to an external API. Use the 'secret' parameter to inject authentication from the secrets store, you never see the raw credential. Supports GET, POST, PUT, PATCH, DELETE. JSON bodies are auto-serialized. Responses with JSON content-type are auto-parsed.

  • body string Request body. Objects are JSON-serialized automatically.
  • headers object Additional HTTP headers as key-value pairs.
  • method string HTTP method: GET, POST, PUT, PATCH, DELETE. Default: GET.
  • secret string Named secret from the secrets store. The runtime injects the auth header, you never see the raw key.
  • timeout integer Timeout in milliseconds. Default: 30000, max: 120000.
  • url string, required Full URL to call (https recommended)

screen

screen.capture(scale)screen

Capture a screenshot of the screen. Returns a base64 PNG image.

  • scale number Image scale factor 0.1-2.0 (default 1.0)
screen.displays()

Get display information: size, position, and visible area (excluding dock/menubar) for all connected displays. No screen recording permission required. Use this to calculate port positions before calling port_move.

screen.record(options)screen

Record an app surface for a fixed number of seconds and auto-stop (the convenience form). Pass options.seconds. Returns {path, width, height, seconds, fps, bytes}.

  • options object Recording options: target ({window:"self"} | {window:<osId>} | {port:<udid>} | {ports:[<udid>...]} | {region:{x,y,w,h}} | {display:<id>}). window/port/ports are occlusion-proof (only that surface); region/display capture the raw display (may catch other apps). Also aspect (e.g. "16:9"), fit (cover|exact; contain is not yet supported), width, height, scale, fps, padding, cursor (bool; only on a display or region target, a window/port capture cannot include the cursor), audio (none|system|mic|both), format (mov|mp4), path, and for the convenience form seconds.
screen.record.start(options)screen

Start recording an app surface (window:self / a port / multiple ports) to a video file with optional system or mic audio. Returns {recordingId, width, height, target}. Stop with screen.record.stop.

  • options object Recording options: target ({window:"self"} | {window:<osId>} | {port:<udid>} | {ports:[<udid>...]} | {region:{x,y,w,h}} | {display:<id>}). window/port/ports are occlusion-proof (only that surface); region/display capture the raw display (may catch other apps). Also aspect (e.g. "16:9"), fit (cover|exact; contain is not yet supported), width, height, scale, fps, padding, cursor (bool; only on a display or region target, a window/port capture cannot include the cursor), audio (none|system|mic|both), format (mov|mp4), path, and for the convenience form seconds.
screen.record.status(recordingId)

Report whether a recording (or any recording) is active and its elapsed seconds.

  • recordingId string Optional recording id; omit for the overall status
screen.record.stop(recordingId)

Stop a recording started with screen.record.start. Returns {path, width, height, seconds, fps, bytes}.

  • recordingId string, required The id returned by screen.record.start
screen.stopStream()

Stop the screen stream and release the capture stream.

screen.stream(options)screen

Start continuous screen streaming. Pushes screen.frame events to the calling port until screen.stopStream.

screen.windows()screen

List all visible windows with their titles, apps, and positions

sessions

sessions.find()terminal

The Claude Code and Codex sessions running on this computer that Port42 did not start, each with its project, branch, title, last activity and the app it runs in, grouped into a space per project. What the first-run import and ⌘K 'bring in running sessions' offer.

sessions.import(sessions)terminal

Bring running sessions into Port42 as forks: each becomes a companion in its space whose terminal starts with a copy of the whole conversation; the original is not touched and should then be closed. Each item: {id, cli, cwd, space, name}.

  • sessions array, required The sessions to bring in: {id, cli (claude|codex), cwd, space, name}.

space

space.create(name, switch)

Create a space. Returns {id, name}. The name is lowercased with spaces as dashes. Pass switch: true to also make it the current space; by default the person stays where they are.

  • name string, required The space's name.
  • switch boolean Also switch to it (default false).
space.current(space_id, port)

Get a space's metadata and member list: { id, name, type, memberCount, members: [{ id, name, type, owner, qualifiedName }] }. Pass space_id to inspect a specific space (e.g. your own PORT42_SPACE_ID); omit it for the currently selected space.

  • port string From another machine: the shared port whose space to read.
  • space_id string Optional space id to inspect. Defaults to the currently selected space.
space.delete(space_id)

Delete a space: its own ports and terminals close, then the space and its chat go. A port adopted into another space stays there. Cannot be undone. The same as Delete in the galaxy.

  • space_id string, required The space to delete (from space_list).
space.list()

List all spaces the user belongs to

space.setWorkingDirectory(space_id, path)

Set (or clear) a space's working directory. Command companions spawned in the space default their cwd here so they share one workspace; each still gets its own claude session. Clearing falls back to home, and is a deliberate act: send path as null (or an empty string). OMITTING path is an error, not a clear. Defaults to the current space.

  • path required Absolute directory path. Send null or "" to clear it and fall back to home. Required: omitting it is refused with missing_arg, so a malformed call cannot silently clear the setting.
  • space_id string Space id (default: current space).
space.switchTo(space_id)

Switch the app's current space by id.

storage

storage.delete(key, options)

Delete a value from persistent storage

  • key string, required The storage key to delete
  • port string For a copy of a port shared from another machine: that port's id, to reach its own storage there.
  • scope string "global" for storage shared across spaces; omit for this space's.
  • shared boolean true for the space's shared bucket rather than the caller's own.
storage.get(key, options)

Get a value from persistent key-value storage

  • key string, required The storage key
  • port string For a copy of a port shared from another machine: that port's id, to reach its own storage there.
  • scope string "global" for storage shared across spaces; omit for this space's.
  • shared boolean true for the space's shared bucket rather than the caller's own.
storage.list(options)

List all keys in persistent storage

  • port string For a copy of a port shared from another machine: that port's id, to reach its own storage there.
  • scope string "global" for storage shared across spaces; omit for this space's.
  • shared boolean true for the space's shared bucket rather than the caller's own.
storage.set(key, value, options)

Store a value in persistent key-value storage

  • key string, required The storage key
  • port string For a copy of a port shared from another machine: that port's id, to reach its own storage there.
  • scope string "global" for storage shared across spaces; omit for this space's.
  • shared boolean true for the space's shared bucket rather than the caller's own.
  • value string, required The value to store

terminal

terminal.exec(command, options)terminal

Execute a shell command and return the output. Runs in /bin/zsh.

  • command string, required The shell command to execute
  • cwd string Working directory (default: home)
  • timeout integer Timeout in seconds (default: 30, max: 120)

user

user.get()

Get the current user's identity (id and display name)

The source is MIT licensed and public on GitHub.

Shipped and next

Roadmap

Port42's first commit landed on March 7, 2026, and 58 releases shipped before v1. Nautilus is Port42 v1.

Shipped
M1
Local chat shell
Native Mac app. Spaces, messages, local persistence, keyboard shortcuts.
✓ shipped
M2
Bring your own agent
Model companions, @mention routing, streaming, invite links, the Python SDK. Retired in v1: companions are now CLIs such as Claude Code and Codex, and Port42 runs no model.
✓ shipped
M3
Sync and multiplayer
A relay, real-time sync, presence, and space invites. Retired in v1: replaced by sharing one port with one invite, encrypted end to end.
✓ shipped
M4
Ports
Inline ports, pop out and dock, the bridge API, port storage, generative ports.
✓ shipped
M5
OpenClaw integration
One-click connection to a local OpenClaw gateway. Retired in v1: any CLI or agent framework joins as a companion instead.
✓ shipped
M6
Device APIs
Terminal, clipboard, files, notifications, audio, camera, screen, browser, and automation, as companion capabilities.
✓ shipped
M7
Unified API
One API for ports and conversation, with permissions per caller.
✓ shipped
M8
The Shell
Ports became the desktop, with zoom from every space to one port.
✓ shipped
Now
v1
Port42 v1 · Nautilus
Everything is a port, a chat in every space and port, Claude Code and Codex out of the box, /imagine, many drivers on one port, ports that feed ports, and one port shared with one invite, live in Port42 or in any browser, with a copy they can fork into their own space when you allow it.
shipping
Next
→
Ports that say what they are doing
A port too small to draw, hidden, or listed in ⌘K shows one line of its state, such as "building the join card · 3 of 5 · 0 errors". The port or its companion declares it, and other companions can read it.
first after v1
→
Crews for imagine
The lead and two members take roles that fit the kind of thing you imagine, such as an analyst and an engineer for data, or a designer and an engineer for design. Same size and cost as today.
coming soon
→
Share a whole space
One invite for a whole space, beside today's one invite per port.
planned
→
Share with the people in the chat
A companion can offer a port it just made to everyone already in the shared chat, and a port id in a chat becomes a link that opens it.
planned
→
More AI companions
First-run setup for the other major coding CLIs beside Claude Code and Codex, including Gemini CLI, GitHub Copilot CLI, Cursor CLI, Amp, OpenCode, Aider, Goose and Qwen Code. Any of them already runs in a terminal port.
planned
→
Hosted agents as companions
Agents that run as a service, rather than as a CLI on your computer, join a space as companions.
planned
→
Pick the model
Choose a companion's model from the models its CLI offers, instead of typing the CLI's flag.
planned
→
Token usage, back
What each companion, space and imagine team spends over time, read from the CLIs' own logs.
planned
→
Record your workflow, then rebuild it
Port42 counts your window switches and copy loops for a few minutes, on your computer. A companion proposes a space that removes them, and the same count runs again.
planned
→
Move any port between spaces
Browser and terminal ports move to another space the way web ports do, keeping their page or session.
planned
→
Structured chat
A message carries data as well as text, such as a port, a file or a result, so companions and people can exchange data as well as words.
planned
→
MCP as a port capability
MCP tools inside a port, running with the viewer's own credentials.
planned
→
Computer use
A companion that sees the screen and acts on it in one loop.
planned
→
Mac apps in spaces
Other Mac apps organized into your spaces beside your ports.
research
→
A live media plane
Ports and companions as live media tracks across machines, native video ports, and spaces across several displays.
planned
→
Permissions, simpler
One guided permission flow, and the program as the credential, a caller known by its code signature.
planned
→
Windows and iOS
Port42 beyond the Mac.
coming soon
Open source

Contributing

Port42 is open source under the MIT license, and contributions are welcome.

Bug fixes and small improvements

Open a pull request. Fork, branch, fix, and submit with a clear commit message.

New features and major changes

Major changes start with a Port42 Proposal (P42P) before any code is written. The port contract and the gateway API are shared by you, your AI companions, and every other program that calls them, so a companion that works today should still work tomorrow. A P42P covers the user flows, the architecture, acceptance criteria, changes to the contract, security implications, and an implementation plan with tests.

What counts as major: new user-facing features, changes to the port contract or the gateway API, architectural changes, and removing or changing existing behavior. The full guidelines and the P42P template are in CONTRIBUTING.md.