A terminal API client for gRPC and REST in the spirit of Postman, built with Ink. Everything stays on your machine.
- gRPC: discover services via server reflection (v1 and v1alpha),
.protofiles or protosets; call unary, server-, client- and bidi-streaming methods with live output, headers, trailers, status and timing. - REST / HTTP: any method,
:pathvariables, query params, headers, JSON/text/XML/form/multipart/file/GraphQL bodies, bearer/basic/API-key/OAuth2-token auth, redirects, timeouts, live streaming (SSE) and binary downloads. - Collections: folders, requests, variables, auth and header inheritance, environments, tabs, and request chaining (pick a value from one response and use it in another).
- Import: Postman v2.1 JSON, Postman v3 YAML, Bruno
.bruand Bruno OpenCollection YAML (with gRPC). Export: Postman v3 YAML and v2.1 JSON. - Scriptable CLI for listing, describing, calling, running saved requests and printing
curl/grpcurlequivalents.
◆ ferry env Local · Say hello localhost:50051/demo.greeter.v1.Greeter/SayHello ? help
╭──────────────────────────────╮╭───────────────────────────────────────────────────────────────╮
│ 1 Collections 2 Services ││ Request Demo API › Greeter │
│──────────────────────────────││ Name Say hello │
│▾ Demo API ││ › URL {{host}} → localhost:50051 │
│ ▾ Greeter ││ Method /demo.greeter.v1.Greeter/SayHello [unary] │
│ ◆ Say hello ││ Metadata x-request-id │
│ ◆ Stream greetings ││ Auth inherit → none │
│ ◆ Collect names ││ Settings plaintext · reflection (collection) │
│ ◆ Chat ││ ───────────────────────────────────────────────────────────── │
│ ▸ Inventory 4 ││ Message HelloRequest ✓ │
│ ▸ REST health 1 ││ 1 { │
│ ││ 2 "name": "{{user}}", │
│ ││ 3 "language": "LANGUAGE_FRENCH" │
│ ││ 4 } │
│ │╰───────────────────────────────────────────────────────────────╯
│ │╭───────────────────────────────────────────────────────────────╮
│ ││ Response Messages │ Metadata (3) │
│ ││ ● OK (0) · 32 ms · 1 msg │
│ ││ { │
│ ││ "message": "Bonjour, Ada!", │
│ ││ "sentAt": "2026-09-25T06:41:01.975Z" │
│ ││ } │
╰──────────────────────────────╯╰───────────────────────────────────────────────────────────────╯
tab: focus · enter: edit · t: template · o: $EDITOR · ctrl+r: send · ctrl+s: save · e: env
Requires Node 20+.
npm install
npm run build
# terminal 1: demo servers — gRPC with reflection on localhost:50051, REST on localhost:8089
npm run demo-server
# terminal 2: import the sample collection, pick the environment, open the UI
node dist/cli.js import examples
node dist/cli.js env Local
node dist/cli.jsUse npm link to put ferry (alias fy) on your PATH. npm run dev runs from source without building.
Three panes: the sidebar (Collections / Services tabs), the request editor and the response viewer. Tab cycles focus, and ? shows every shortcut.
| Key | Action |
|---|---|
ctrl+r |
send the request (works while editing) |
ctrl+s |
save; scratch requests ask which collection or folder to save into |
ctrl+c |
cancel a running call, close a modal, or quit |
1 / 2 |
Collections / Services tab |
e |
environments: switch, create, edit variables, mark secrets |
N |
new scratch request |
R |
re-run discovery for the current target |
i |
import Postman v3 YAML |
L |
toggle layout: request above response, or side by side (remembered) |
B |
hide/show the sidebar for more room (remembered) |
C |
show the request as a runnable curl / grpcurl command; y copies it |
p |
preview what will be sent: every header/metadata entry with its source, TLS material, target (v reveals secrets) |
P |
per-host certificates (client certs and CAs for mTLS) |
Tabs. Opening a request from the sidebar (or N for a scratch request) opens it in a tab. Each tab keeps its own unsaved draft, response and scroll position, and a call can keep running in a background tab while you work in another; the tab shows a spinner, then a green or red dot. Open tabs are restored the next time you start (scratch tabs aren't).
| Key | Action |
|---|---|
[ / ] |
previous / next tab |
T |
fuzzy-searchable list of open tabs |
{ / } |
move the current tab left / right |
alt+1…alt+9 |
jump to tab N (terminals that send Alt as Meta) |
w / W |
close the tab / close all other tabs (asks if there are unsaved changes) |
Collections tab: n new request · f folder · c collection · r rename · d delete · y duplicate · K/J reorder · v variables · s collection settings (schema source, field names, TLS files) · a collection auth · x export.
Services tab lists what the current request's target exposes. enter puts the method on the current request and fills in an example message. d shows the proto definition, and a adds the method to a collection as a new request.
Request pane: arrow keys pick a field and enter edits it. Name and URL edit inline. Method opens a fuzzy picker. Metadata opens a table editor, and Auth and Settings open forms. Message opens an inline JSON editor: esc to finish, ctrl+f to format, auto-indent. You can also press o to edit the message in $VISUAL/$EDITOR, t to generate a template from the schema, or f to format.
Response pane: scroll with arrows, pgup/pgdn and g/G. m (or ←/→) cycles between messages, headers/trailers and timing, y copies to the clipboard, and x clears the response.
In modals, esc saves and closes; ctrl+c discards.
| Field | Notes |
|---|---|
| URL | host:port, grpc://host:port, or grpcs://host (TLS, port 443 by default). Supports {{variables}}. |
| Method | /pkg.Service/Method; pkg.Service/Method and pkg.Service.Method are also accepted. |
| Message | Proto3 canonical JSON: camelCase or original field names, enums by name or number, int64 as strings, Timestamp/Duration/FieldMask/wrappers/Struct/Any in their JSON forms. The protobufjs object form that Bruno and @grpc/proto-loader use is accepted too ({"seconds": …, "nanos": …} timestamps, {"value": …} wrappers, [{"key": …, "value": …}] maps) and converted using the schema. Unknown fields are rejected before sending. Responses and templates use camelCase JSON names unless the collection's Field names setting (s in the sidebar) is set to proto names (tenant_id); ferry call and ferry describe --template take --proto-names. For client and bidi streaming, use a JSON array: each element is sent as one message, then the stream is half-closed. |
| Metadata | Key/value pairs. Keys ending in -bin take base64 values. |
| Auth | inherit (nearest folder/collection), noauth, bearer, basic, apikey. Converted to metadata; explicit metadata wins. |
| Settings | TLS on/off, certificate verification, CA/client cert/key files, schema source, emit default fields, connect timeout, max response size. |
| Field | Notes |
|---|---|
| Method | enter picks GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS. |
| URL | {{vars}} and :name path segments. Typing ?a=1&b=2 moves those into Params; a missing scheme means http://. |
| Params / Path vars | Query parameters (URL-encoded on send, can be disabled) and values for :name segments. |
| Headers | Merged with collection and folder headers (Bruno); auth adds Authorization unless you set it yourself. |
| Auth | inherit, noauth, bearer, basic, apikey (header or query parameter), oauth2 (sends the access token). |
| Body | none, JSON, text, XML, HTML, JavaScript, form-urlencoded, multipart (a value starting with @ sends that file), binary file, GraphQL (query + variables). enter on Content edits text bodies; o opens $EDITOR. |
| Settings | Follow redirects (and max), verify TLS certificates, timeout. |
Responses show status, time, bytes sent (↑, the request body or protobuf messages) and received (↓), content type, a Body tab (JSON with selectable values, text, or a binary notice), a Headers tab and a Timing tab. Streamed bodies such as text/event-stream appear as they arrive. In the response pane, s saves the body to a file (named from Content-Disposition when present) and C in any pane shows the request as curl.
The Timing tab (gRPC and HTTP) splits the total into consecutive phases: dns, connect (TCP), tls, http2 (gRPC's connection setup), send (request body upload), wait (request sent → response headers, i.e. one network round trip plus server time) and receive. When the response carries x-envoy-upstream-service-time or Server-Timing, the server's own time is shown too, so the network's share of wait is visible. Each gRPC call opens a new connection, so it always pays for dns/connect/tls/http2; HTTP requests reuse open connections and are marked as reused. --verbose on the CLI prints the same breakdown.
Press enter in the response pane to select a value: ↑↓ moves between values, and the status line shows the path, e.g. [0].addressId = 01a0…. Then:
| Key | Action |
|---|---|
u (or enter → Use in another request) |
write the value into a field of another open tab. The best-matching field is preselected (addressId → address_id). If other fields of that request match values in the same response by name (tenant_id ← tenantId, address.zip_code ← company.address.zipCode), a checklist offers them too: space ticks, a ticks all or none, enter applies. Fields that are still empty or template defaults are pre-ticked; ones that already hold a value, or only match on a generic name like id, are not. Then it switches to that tab so you can ctrl+r. |
v (or enter → Save as {{var}}) |
store it as a variable in the active environment (or the collection if no environment is active), then use "address_id": "{{addressId}}" anywhere |
enter → Capture |
same, and refresh it after every successful call. It's stored on the request's Captures field as {{addressId}} ← [0].addressId; edit it there. |
y |
copy the value |
Paths are [message index].field.path, e.g. [0].items[2].sku; streaming responses have one index per message. ferry run applies captures too, so run ".../ListAddresses" followed by run ".../GetAddress" chains from scripts. Captures are exported as Postman after-response scripts (pm.environment.set("addressId", pm.response.messages.idx(0).data.addressId);) and scripts of that exact shape are imported back as captures.
Requests have a Scripts field: pre-request and post-response scripts in JavaScript or TypeScript (types are stripped by Node's built-in type stripping, Node 22.13+). enter on Scripts (in the request pane, below Captures) picks one to edit in place: JS/TS syntax colors, a syntax check as you type (the status line and a ✗ in the gutter show the line), and ctrl+t to test-run the script against the last response (or before sending, for pre-request scripts). Test runs show tests, logs and the variables the script would set, without saving anything. esc keeps your changes, ctrl+x discards them, ctrl+o continues in $VISUAL/$EDITOR; emptying a script deletes it. Collection and folder scripts run too (collection → folders → request), as in Postman.
A typical token request stores the token, and every gRPC call sends it through metadata authorization: Bearer {{accessToken}}:
// "Get token" → post-response. Bruno style:
const body = res.getBody();
if (!body?.access_token) throw new Error('No token: ' + JSON.stringify(body));
bru.setEnvVar('accessToken', body.access_token);
// or Postman style:
pm.environment.set('accessToken', pm.response.json().access_token);Send it (ctrl+r, or ferry run "My API/Auth/Get token") and {{accessToken}} is saved to the active environment (else the collection), keeping its secret flag. It's written to the environment file, so it survives restarts and later ferry runs.
| API | Available |
|---|---|
| Postman | pm.environment / pm.collectionVariables / pm.globals (get, set, unset, has, replaceIn), pm.variables (values for this send only, e.g. from a pre-request script), pm.response.json() / .text() / .code / .status / .headers.get() / .responseTime / .to.have.status(), gRPC pm.response.messages.idx(n).data / .count() / .metadata / .trailers, pm.request, pm.info, pm.test, pm.expect |
| Bruno | bru.setEnvVar / getEnvVar / setVar / getVar / deleteVar / getCollectionVar / getEnvName / getProcessEnv / interpolate / sleep, res.getBody() / res.body / res.getStatus() / res.getHeader() / res.getResponseTime() / res('path.to.value'), req.getUrl() / getMethod() / getHeader(), test, expect |
| Also | console.log (shown under Metadata/Headers in the response pane, m), await, crypto, Buffer, atob/btoa, URL, and require() of crypto, buffer, url, querystring, util, path |
For gRPC, res.getBody() / pm.response.json() return the response message (an array for streams). Bruno's session-only bru.setVar is stored in the environment so chaining works across runs. Not supported: pm.sendRequest, changing the request's headers from a script (use {{vars}} in headers instead), and chai's full expect (the common assertions are there: equal, eql, include, property, a/an, ok, above/below, lengthOf, match, not, …).
A failing pre-request script stops the request. Post-response scripts run for every response that has a status (including HTTP 4xx/5xx and non-OK gRPC statuses); errors and failed tests show at the top of the response and in the status line. Scripts get a 10-second timeout per script.
Scripts are code. They run in a separate V8 context, which isn't a security sandbox: a script can do what you can. Imported collections' scripts now run on send, so only send requests from collections you trust.
ferry run --no-scriptsskips them.
TLS. gRPC turns TLS on with grpcs:// or Settings → TLS; HTTP uses it for https://. For both you can set a CA certificate (to trust a private CA), a client certificate + key (PEM) or a PKCS#12 bundle (.p12/.pfx) for mTLS, and a passphrase ({{var}} works). Certificate verification can be turned off per request. gRPC also has a server name override that sets the TLS name and :authority independently of the address — for tunnels, port-forwards and proxies (e.g. connect to localhost:9443 but verify svc.internal).
TLS material layers: per-host rule (P, or ferry cert add) < collection (s on a collection) < request (Settings). Host rules match host, host:port or *.domain; the most specific wins. TLS failures are explained ("the server's certificate isn't trusted: set a CA…", "the certificate is for a different name: set a server name override", "the server wants a client certificate").
Authorization (per request, or inherited from folders/collections with a in the sidebar):
| Type | Sent as |
|---|---|
| bearer | Authorization: Bearer <token>; a token saved with the Bearer prefix isn't doubled, and an empty token sends nothing (with a warning) |
| basic | Authorization: Basic … |
| API key | a header (gRPC metadata) or, for HTTP, a query parameter |
| OAuth 2.0, client credentials | ferry POSTs grant_type=client_credentials (client id/secret in the body or as Basic, optional scope and audience) to the token URL, caches the token until it expires (~/.ferry/tokens.json, mode 600) and sends Authorization: Bearer <token> — for HTTP and gRPC. ferry token clear forces a new one. |
| OAuth 2.0, access token | a token you paste (or {{accessToken}}) |
OAuth2 settings use Postman's names, so they import from and export to Postman v2.1/v3, and Bruno's auth:oauth2 blocks import too.
Headers. H on a collection or folder edits headers that every request inside inherits — as HTTP headers and as gRPC metadata (keys lowercased); a request's own header with the same name wins, and an explicit Authorization header wins over auth. p shows the final list with where each entry comes from (request, folder, collection, auth — including which level it's inherited from — or defaults like User-Agent, Content-Type, :authority).
{{name}} works in the URL, method, metadata, auth, message and proto paths. Resolution order is environment > folder > collection, and the nearest scope wins. Dynamic values: {{$guid}}, {{$timestamp}}, {{$isoTimestamp}}, {{$randomInt}}. Unresolved names are flagged when you send.
By default the schema comes from server reflection at the request URL. To use .proto files instead, set Settings → Schema source (per request) or s on a collection (collection default):
- .proto files: comma-separated files plus import paths. Relative paths resolve against the imported collection's folder, else the current directory.
- protoset: a
FileDescriptorSetfrombuf build -o api.binpborprotoc --include_imports -o api.binpb.
Descriptors from protobufjs-based servers (e.g. @grpc/reflection) are repaired automatically. Those servers emit relative type names, per-package synthetic files with no imports, and missing well-known types, which stricter tools reject. For example, grpcurl fails against the bundled demo server, but this client works.
import (or i in the UI) detects the format:
| Format | Point it at |
|---|---|
| Postman v3 YAML | a repo with postman/collections/, a collection folder, *.request.yaml, *.environment.yaml |
| Postman v2.1 / v2.0 JSON | *.postman_collection.json, *.postman_environment.json |
Bruno .bru |
a folder with bruno.json (environments from environments/*.bru) |
| Bruno OpenCollection YAML | a folder with opencollection.yml (HTTP and gRPC requests; environments from environments/*.yml) |
| Bruno environment JSON | { "name", "variables": [...] } |
| Any other folder | scanned up to three levels deep and everything above is imported |
When one folder holds the same collection in several formats (e.g. Postman JSON plus a Bruno copy), the duplicates are labelled (Postman v2), (Bruno), …; identical environments are imported once. After an import, the environment that defines the collection's {{variables}} is activated.
Post-response scripts that just store a response value become captures: pm.environment.set("t", pm.response.json().access_token), the var t = pm.response.json()…; pm.environment.set("t", t) idiom, Bruno's bru.setEnvVar("t", res.body.x) and vars:post-response { t: res.body.access_token }. Other scripts are kept, exported and run (see Scripts).
ferry import ./my-repo # postman/collections + postman/environments
ferry import ./postman/My.postman_collection.json
ferry import ./bruno/Project-Picnic # Bruno .bru
ferry import "./docs/bruno/STF gRPC" # Bruno YAML
ferry export "My API" ./my-repo --with-environments [--replace] [--include-secrets] # Postman v3 YAML
ferry export "My API" ./out --format postman-v2 --with-environments # Postman v2.1 JSON (HTTP only)grpc-requestfiles map directly:url,methodPath,methodDescriptor(kept as-is),message.content,metadata,auth,settings,scriptsandorder. Folder and collectiondefinition.yamlfiles provide names, descriptions,variables,authand ordering.http-requestfiles map to HTTP requests (method,url,headers,queryParams,pathVariables,body,auth,settings,scripts).- Other request kinds (GraphQL, WebSocket, MQTT, …) are shown greyed out and written back unchanged on export, so a mixed collection round-trips.
- Re-importing replaces collections and environments with the same name, keeping their local ids and schema settings. Importing a git checkout again updates it in place.
- Export follows the v3 file rules: single-quoted
{{vars}}and special characters,|-blocks for multi-line JSON, sanitized unique filenames, andname:written only when it differs from the filename. --replaceremoves an existing export of that collection first, so deleted requests don't linger (the UI asks first).- Secret environment values (
type: secret) are written as empty strings unless you pass--include-secrets, because exports usually get committed to git. - Not exported: gRPC schema source, field names, TLS file paths and HTTP timeouts are local-only; they aren't part of the Postman format.
- v2.1 export writes HTTP requests only (v2.1 has no gRPC type; skipped requests are listed), including saved examples that came from a v2.1 import.
ferry list localhost:50051 # services and methods
ferry describe localhost:50051 demo.greeter.v1.Greeter/SayHello
ferry describe localhost:50051 demo.greeter.v1.Greeter/SayHello --template
ferry call localhost:50051 demo.greeter.v1.Greeter/SayHello -d '{"name":"Ada"}' -v
ferry call localhost:50051 demo.greeter.v1.Greeter/Chat -d @messages.json
ferry call api.example.com:443 pkg.Svc/Method --tls -H 'authorization: Bearer ...' -d -
ferry call localhost:50051 pkg.Svc/Method --proto api.proto -I ./protos
ferry ls # collections, requests, environments
ferry env Staging # set the active environment ("none" to clear)
ferry env ./postman/environments/Staging # or import an *.environment.yaml and activate it
ferry run "Demo API/Inventory/Get item" -e Local -v
ferry run "Demo API/Notes (REST)/Get token" # HTTP; runs scripts and captures ({{token}})
ferry run "Demo API/Notes (REST)/Get token" --no-scripts # skip pre-request / post-response scripts
ferry run "Demo API/Notes (REST)/Download report" -o report.pdf # write the body to a file
ferry curl "Demo API/Notes (REST)/Echo form" # equivalent curl (grpcurl for gRPC)
ferry preview "Demo API/Notes (REST)/List notes" [--reveal] # final headers/metadata with sources, TLS, target
ferry cert add "*.symmetrydev.com" --ca ./ca.pem --cert ./me.pem --key ./me.key # or --pfx me.p12 --passphrase '{{p12pass}}'
ferry cert ls
ferry cert rm "*.symmetrydev.com"
ferry token clear # drop cached OAuth2 tokensResponses print as JSON to stdout (streamed gRPC messages print as they arrive; HTTP JSON bodies are pretty-printed, text is printed as-is), and diagnostics go to stderr. The exit code is the gRPC status code on a non-OK gRPC status, and 1 for HTTP 4xx/5xx or when a script throws or a test fails. Script output (console.log, variables set, tests) goes to stderr.
Collections and environments are stored as JSON under ~/.ferry/ (override with --home or FERRY_HOME). Data from the app's earlier name, ~/.grpc-client/, is moved there automatically the first time you run ferry. This is plain local storage: environment values, certificate passphrases (settings.json) and cached OAuth2 tokens (tokens.json, mode 600) are not encrypted, so prefer {{variables}} over hard-coding secrets in collections you export.
npm run dev # run the UI from source (tsx)
npm run typecheck
npm test # vitest: core, Postman v3 round-trip, and gRPC integration tests against the demo server
npm run demo-server # PORT=6000 npm run demo-server to change the portsrc/
cli.tsx commander entry: UI (default) + subcommands
core/
model.ts collection/request/environment model (mirrors v3 grpc-request)
postman-v3.ts v3 YAML import/export
workspace.ts on-disk store + mutations
resolve.ts variables, auth inheritance, effective settings
vars.ts {{var}} resolution
scripts.ts pre-request / post-response scripts (pm.*, bru.*, JS or TS)
grpc/
reflection.ts server reflection client (v1 → v1alpha fallback)
proto-files.ts .proto and protoset loading
schema.ts descriptor repair, registry, templates, describe
discovery.ts cached schema loading per target
invoke.ts unary/streaming calls with proto3 JSON mapping
connection.ts URL parsing, credentials, metadata
ui/
App.tsx state, key routing, modals
components/ Sidebar, RequestPanel, ResponsePanel, TextEditor, TextField, Modals, EnvManager
examples/
protos/ demo service definitions
server/server.ts demo server (reflection enabled; Inventory needs "Bearer demo-token")
postman/ sample v3 collection + environment