apicat
v0.3.30
Published
Quick and flexible API tool for Node.js CLI and JavaScript
Readme
apicat is a tiny API caller.
Keep your API definitions in YAML, then list them, inspect them, and fire them off from the CLI or from JavaScript. It is built for quick experiments, repeatable calls, and "what was that curl again?" moments.
⚡ Quick Start
npx apicat <service.name> KEY=VALUEOr install it locally. Use apic instead of npx apicat.
npm install -g apicat
$ apic ls
$ apic httpbin.get🔌 apicat v0.3.25 — call APIs (apic)
Commands
apic <service.name> [k=v …] Call API with optional params
apic ls|list [pattern] List APIs (e.g. apic list "openrouter")
apic update Copy latest published .apicat to ~/.apicat
apic help [service|pattern] Show help or search config for pattern
apic <service.name> --help Show help for this api call
apic proxy -p <port> [-P <backend host:port>] [-B|--bearer <env key name>]
Forward HTTP requests; -P pins the backend target, -B adds a Bearer auth header from an env var
Options
apic <service.name> --time Show request duration
apic <service.name> --debug Show fetch request/response info + full response body
apic <service.name> --response Output raw response (skip jq filter)
apic <service.name> --stream Stream response events / tokens (default if not specified)
apic <service.name> --no-stream Disable streaming (wait for full response)
apic --config <path> httpbin.get Use custom config file instead of ~/.apicat
apic -h, --help Show help🤖 Model Context Protocol (MCP) Server for AI Agents
apicat includes an ultra-fast, native Rust MCP server (mcp/) that lets AI coding assistants (Antigravity, Cursor, Claude Desktop, Claude Code, Windsurf, Cline) call and execute API definitions with zero token-reading overhead, sub-5ms cold starts, and in-memory TLS connection pooling.
1. Compile the MCP Binary
cargo build --release --manifest-path mcp/Cargo.tomlThe compiled binary will be at mcp/target/release/apicat-mcp.
2. Configure Your AI Agent
Add apicat to your agent's MCP configuration (mcp_config.json, .cursor/mcp.json, or claude_desktop_config.json):
{
"mcpServers": {
"apicat": {
"command": "/absolute/path/to/apicat/mcp/target/release/apicat-mcp"
}
}
}⚡ Empirical Performance & Benchmark Matrix
1. Real-World Autonomous Agent Task Performance
Measured on an AI billing and quota audit task across Fireworks AI and OpenRouter:
| Strategy | Total Wall Time | LLM Turns | Context Token Footprint | Discovery Overhead |
| :--- | :---: | :---: | :---: | :--- |
| Unassisted Agent (CLI & Search) | 21.0s | 10 turns | ~18,400 tokens | 5 exploratory tool calls |
| Skill-Assisted CLI (apic) | ~4.5s | 1–2 turns | ~1,200 tokens | 0 tool calls |
| In-Memory Rust MCP Server | ~2.5s | 1 turn | ~650 tokens | 0 calls (6.0x faster, 96.5% token savings) |
2. Three-Way Batch API Execution
Batch execution across four live API calls (catfact.getFact, fireworks.balance, openrouter.key, fireworks.costs):
| Endpoint | Raw curl (Subprocess) | apic CLI (Node.js) | In-Memory Rust MCP | Improvement |
| :--- | :---: | :---: | :---: | :---: |
| catfact.getFact | 157.8 ms | 390.5 ms | 117.6 ms | 🚀 1.34x faster (+40 ms) |
| openrouter.key | 188.3 ms | 358.4 ms | 131.1 ms | 🚀 1.44x faster (+57 ms) |
| fireworks.costs | 1,198.7 ms | 1,242.4 ms | 783.1 ms | 🚀 1.53x faster (+416 ms) |
| fireworks.balance | 2,748.1 ms | 1,394.6 ms | 1,742.0 ms | 🚀 1.58x faster (+1,006 ms) |
| Total Batch (4 Calls) | 4,292.9 ms | 3,385.8 ms | 2,773.8 ms | ⚡ 1.55x faster (+1.52s saved) |
3. MCP Runtime Architecture Comparison
Comparing MCP server implementations across languages:
| Metric | Rust (Native Release) | Bun (TypeScript) | Python 3.14 (Stdlib) | | :--- | :---: | :---: | :---: | | Cold Start & Handshake (Spawn → Init → tools/list) | 4.36 ms | 37.24 ms | 42.79 ms | | Raw JSON-RPC IPC Latency (Stdio Ping) | 0.018 ms (18 µs) | 0.058 ms (58 µs) | 0.028 ms (28 µs) | | Memory Footprint (RSS) | 1.77 MB | 22.83 MB | 17.45 MB | | Host Runtime Dependencies | None (Standalone binary) | Requires Bun/Node | Requires Python |
4. The Impact of In-Memory Connection Pooling (Keep-Alive)
Reusing warm TCP and TLS 1.3 encryption sockets eliminates DNS and handshake latency:
- Cold Socket (
openrouter.key): 4,066 ms - Warm Reused Socket (
openrouter.key): 468 ms (8.7x speedup)
🤖 Prompting LLMs directly
If you want an AI to learn your raw API definitions without MCP:
Learn api definitions from https://unpkg.com/apicat
🎉 API goodness
- One command:
apic - One bundled config file:
~/.apicat - HTTP and WebSocket support
- Declarative WebSocket keep-alive, liveness checks, and reconnect
- Simple forward proxy:
apic proxy -p <port> [-P <backend host:port>] - Variables with
$VARand required variables with$!VAR - Works as a CLI, a library, and an exported CLI module
🧠 How It Thinks
On first interactive run, it can copy the bundled apicat.yaml to ~/.apicat. Edit to your liking.
When an .apicat file exists in the current directory, it automatically overrides ~/.apicat. If an apicat.yaml file exists in the current directory, those definitions get added to what is in ~/.apicat (or ./.apicat) for the client to use.
Variables can be defined in the call or will be used if named the same in env.
API IDs use <service>.<name> form, like httpbin.get, openai.chat, or echo.ws.
🧰 CLI Cheatsheet
# show the menu
apic
# list available apis
apic ls
# show help for an API
apic openai.chat --help
# use a different config
apic --config ./custom.yaml ls
# time or debug your calls
apic httpbin.get --time
apic httpbin.get --debug
# debug also prints the full raw response body to stderr,
# even when the API's jq filter would normally trim it
# output the raw response, skipping any jq filter
apic httpbin.get --response
# refresh ~/.apicat from the published apicat.yaml
apic update
# forward HTTP requests through a local proxy
# without -P it forwards to the host in each request
apic proxy -p 8080
# with -P it pins the backend: all requests go there
# a bare port (e.g. -P 3000) means localhost:3000
apic proxy -p 8080 -P api.example.com:443
# OpenAI-compatible chat
apic openai.chat \
OPENAI_URL=https://api.openai.com \
OPENAI_API_KEY=$OPENAI_API_KEY \
MODEL=gpt-4o-mini \
PROMPT="Write a haiku about logs"
# OpenRouter
apic openrouter.chat \
API_KEY=$OPENROUTER_API_KEY \
MODEL=openrouter/auto \
PROMPT="Say hello"
# macOS: use clipboard text as a prompt
PROMPT="$(pbpaste)" apic <service.name>Key Value Parameters
apic openrouter.chat MODEL="openrouter/auto" PROMPT="Reply with only: ok"
Values will automatically be used if they exist in env. e.g. API_KEY
export API_KEY=...
# In the yaml config
openrouter.chat:
url: https://openrouter.ai/api/v1/chat/completions
method: POST
headers:
Authorization: "Bearer $!API_KEY"
body: |
{
"model": "$!MODEL",
"messages": [{"role": "user", "content": "$OPTIONAL_PROMPT"}, {"role": "user", "content": "$!PROMPT"}]
, "provider": {"order": ["$PROVIDER"]}
}
jq: .choices[0].message.content
help: Create an OpenRouter chat completion. Requires API_KEY, MODEL, and PROMPT.💻 Use It From Code
Install it locally if you want to import it:
npm install -g apicatThen:
import { fetchApi, getApis, getRequest } from 'apicat';
const apis = getApis();
console.log(apis.map((api) => api.id));
const req = getRequest('httpbin', 'get');
console.log(req.url);
const res = await fetchApi('httpbin', 'get');
console.log(await res.json());
const chat = await fetchApi('openai', 'chat', {
vars: {
OPENAI_URL: 'https://api.openai.com',
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MODEL: 'gpt-4o-mini',
PROMPT: 'Hello world'
}
});
console.log(await chat.json());To load API definitions from a specific file, pass its path as configPath:
const res = await fetchApi('httpbin', 'get', {
configPath: './custom.yaml'
});Without configPath, apicat checks for .apicat in the current directory (which overrides ~/.apicat), falls back to ~/.apicat when present (or the bundled apicat.yaml), and merges in any definitions from apicat.yaml found in the current directory.
For an OpenRouter chat completion, pass the values referenced by the openrouter.chat definition in vars:
import { fetchApi } from 'apicat';
const res = await fetchApi('openrouter', 'chat', {
vars: {
API_KEY: process.env.OPENROUTER_API_KEY,
MODEL: 'openrouter/auto',
OPTIONAL_PROMPT: 'Be concise.',
PROMPT: 'Give me one interesting cat fact.'
}
});
if (!res.ok) throw new Error(`OpenRouter request failed: ${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data.choices[0].message.content);YAML config
Simple! See apicat.yaml.
WebSocket keep-alive and reconnect
WebSocket APIs can be finite request/response calls or long-lived listeners. Long-lived protocols can define heartbeat, liveness, and reconnect behavior declaratively in YAML:
example.listener:
url: wss://example.com/socket
capture:
sequence: .s
heartbeat_interval: .d.heartbeat_interval / 1000
keep_alive:
interval: $heartbeat_interval
send:
op: 1
d: $sequence
expect:
op: 11
timeout: 10
reconnect:
enabled: true
initial: 1
maximum: 60
multiplier: 2
jitter: 0.2capture values are available to later WebSocket flow steps and heartbeat messages. expect is matched as a partial object, so op: 11 matches any incoming JSON message whose op field is 11.
When called from the CLI, WebSocket connection status is written to stderr with comment-style prefixes:
# Connected to: wss://example.com/socket
# Disconnected (closed)File uploads
Use file to send a local file as the raw request body. FILE is a convention; any substituted variable can provide the path.
cloudflare.r2Upload:
url: https://api.cloudflare.com/client/v4/accounts/$!CLOUDFLARE_ACCOUNT_ID/r2/buckets/$!CLOUDFLARE_BUCKET/objects/$!FILE_NAME
method: PUT
file: $!FILE
headers:
Authorization: Bearer $!CLOUDFLARE_API_KEY
Content-Type: $CONTENT_TYPEUse a scoped Cloudflare API token for CLOUDFLARE_API_KEY. FILE is the local path and FILE_NAME is the R2 object key.
Downloads write response bytes to FILE_PATH:
cloudflare.r2Download:
url: https://api.cloudflare.com/client/v4/accounts/$!CLOUDFLARE_ACCOUNT_ID/r2/buckets/$!CLOUDFLARE_BUCKET/objects/$!FILE_NAME
method: GET
headers:
Authorization: Bearer $!CLOUDFLARE_API_KEY
output: $!FILE_PATHapic cloudflare.r2Download FILE_NAME=logo.png FILE_PATH=./downloaded-logo.pngFor multipart/form-data APIs, use multipart. A field containing file becomes a file part; all other fields are sent as text.
example.multipartUpload:
url: https://example.com/upload
method: POST
multipart:
document:
file: $!FILE
content_type: $CONTENT_TYPE
description: $DESCRIPTIONYou may set filename alongside file and content_type; otherwise apicat uses the local filename. Do not set a multipart Content-Type header manually—apicat supplies the required boundary.
Slack
slack.all is a built-in CLI command that exports accessible channels, DMs, group DMs,
and thread replies as NDJSON. It uses a user token and only reads conversations
available through the approved scopes and Slack retention rules. It does not download
file attachments. Public channels may be accessible even when you have not joined them.
Create a Slack app at https://api.slack.com/apps using
the supplied manifest, then copy its Client ID.
The app must enable PKCE and register http://localhost:8765/slack/callback.
Workspace policy may require administrator approval. No client secret is needed.
Slack documents the desktop localhost exception in its
PKCE guide.
apic slack.all CLIENT_ID=123.456 > slack.ndjson
# Subsequent calls use the saved account and automatically refresh its token:
apic slack.all SINCE=2026-10-01 > recent.ndjson
apic slack.all WORKSPACE=T123 CHANNELS=C123,D123 > selected.ndjson
apic slack.all RESUME=true >> slack.ndjson
apic slack.login CLIENT_ID=123.456
apic slack.logout WORKSPACE=T123With no token, apicat starts a temporary localhost HTTP server and opens Slack's
authorization page. It checks OAuth state, exchanges the code using PKCE, closes
the server, and continues the command. If the browser cannot open, use the printed
URL on the same machine. Login times out after three minutes. SLACK_CLIENT_ID
can supply the client ID instead. A different PORT must also be registered in
the Slack app. The callback never binds to a public network interface.
Persistent settings live under ~/.apicat-settings/ (directory mode 0700):
slack.json: client ID, callback port, default workspace/account, and optionalchannelsarray of conversation IDs.CHANNELSoverrides this local allowlist.credentials.json: account metadata, plus tokens when Keychain is unavailable. On macOS tokens are stored in Keychain under serviceapicat.slackwhen possible. Fallback files are plaintext with mode0600; all JSON writes are atomic.slack-checkpoints.json: completed conversations and a fixed export cutoff.slack.lock: prevents simultaneous Slack commands from racing token refresh or checkpoints. After a hard crash, remove this directory only when no Slack command is running.
RESUME=true uses the same workspace/user, SINCE, and channel selection as the
interrupted export. Append to its original output file. The interrupted conversation
is replayed; deduplicate by workspace, channel, and message.ts if necessary (thread
broadcasts can also repeat a reply). Successful exports remove their checkpoint.
Each output line contains workspace, channel, channel_name, and the original
Slack message object. Progress and login URLs go to stderr.
All pages and threads are fetched, including replies to parents older than SINCE.
Consequently a narrow time window can still require scanning old channel history.
Slack rate limits are honored using Retry-After; large exports can take substantial
time. Missing scopes, inaccessible conversations, and pagination failures produce a
nonzero exit status rather than silently claiming a complete export. The output is
not an atomic Slack snapshot: conversations may change during an export.
slack.logout removes the selected local account's tokens; revoke the app separately
in Slack if desired. The JavaScript API supports
fetchApi('slack', 'all', { vars: { SINCE: '2026-10-01' } }) and returns a streaming
NDJSON Response. Consume its body to complete the export; stream errors indicate
an incomplete export. The Rust MCP server delegates these entries to its existing
apic subprocess fallback, so the updated Node CLI must be installed on PATH.
Listening for Slack events
apic slack.watch keeps a Socket Mode WebSocket open and writes new message events,
thread replies, edits, and deletions as NDJSON. It acknowledges Slack envelopes,
reconnects with backoff and fresh connection URLs, and remembers the last 10,000
event IDs across restarts to suppress retries. Ctrl+C stops it and releases its lock.
Open Slack app settings and select the apicat app
you created for your workspace. In the left sidebar, enable Socket Mode, then
enable Event Subscriptions and
subscribe on behalf of users to message.channels, message.groups,
message.im, and message.mpim. The supplied manifest includes these settings.
Click Save Changes, and complete reinstallation if Slack prompts for it.
Socket Mode alone does not subscribe to events. Ensure the app-level token comes
from this same app, and test in a channel joined by the authorized user in the
selected workspace. apic slack.watch --debug reports event types and filter
decisions without logging message contents or credentials. If no events arrive
after 30 seconds, the watcher prints a subscription troubleshooting reminder.
Under Basic Information > App-Level Tokens, generate an xapp- token with
connections:write. This app-level token is separate from the user OAuth token;
no new user scopes are required beyond the existing message-history scopes.
Supply the token locally with a hidden prompt (zsh):
read -rs 'SLACK_APP_TOKEN?Slack app token: '; echo
export SLACK_APP_TOKEN
apic slack.watch
# After Ctrl+C:
unset SLACK_APP_TOKENThe validated token is saved in Keychain when available, or in the protected
credentials file. Later, run apic slack.watch with no token environment variable.
slack.logout removes the user account only; app-level tokens must be revoked
separately in Slack. Do not run multiple consumers for the same app: Slack may
distribute events between their connections.
WORKSPACE=T123 filters events to one workspace (defaults to the saved workspace).
Without either, watch listens across the app's authorized workspaces.
CHANNELS=C123,D123 narrows events to a conversation allowlist. Each output record
has source: "event", workspace, channel, event_id, event_time, and the
original event. Status goes to stderr. fetchApi('slack', 'watch', { signal })
also supports streaming; abort the signal or cancel the response body to stop it.
For optional recovery after a disconnect or restart, use CATCH_UP=true with a
saved user login for the selected workspace. It reads retained history since the
previous connection began while live events queue, and emits source: "history"
records in the slack.all format. History and live events can overlap; deduplicate
by workspace, channel, and message timestamp. History cannot reconstruct deleted
messages or every intermediate edit. Delivery is best-effort, not exactly-once;
events acknowledged before a crash can be lost. A failed history reconciliation
stops the watcher so an incomplete catch-up is not silently ignored.
Watch state and locks use slack-watch-<selection hash>.json and .lock under
~/.apicat-settings/. A running watcher does not hold the ordinary Slack command
lock, so exports and login can run alongside it. After a hard crash, remove its
stale lock only when that watcher is no longer running. Full setup instructions
are also available through apic slack.watch --help.
Discord
Send a message with the saved bot token:
apic discord.post CHANNEL=CHANNEL_ID POST="Hello from apicat"DISCORD_CHANNEL_ID is also accepted instead of CHANNEL. The bot needs View
Channel and Send Messages, or Send Messages in Threads for a thread.
POST accepts 1–2000 characters; mentions do not trigger pings. The command prints
the created message as JSON and does not require Message Content Intent. This
entry replaces discord.channelpost.
discord.all exports messages the bot can read, including active threads, public
archived threads/forum posts, and private archived threads the bot has joined (or
can manage). discord.watch listens for message creation, edits, deletions, and
bulk deletions through Discord's Gateway. Both support the Node fetchApi streaming
interface as well as the CLI.
Create an app at Discord Developer Portal. On its Bot page, create the bot if needed, obtain its token, enable Privileged Gateway Intents → Message Content Intent, and Save Changes. Larger bots may need approval for that privileged intent. In OAuth2 → URL Generator, select the bot scope and View Channels / Read Message History permissions. Open the generated invite URL and authorize the bot in your server. Check channel permission overrides too; reading requires neither Administrator nor Send Messages. Invite the bot to private threads it should read.
Save the token locally using a hidden zsh prompt:
read -rs 'DISCORD_BOT_TOKEN?Discord bot token: '; echo
export DISCORD_BOT_TOKEN
apic discord.login
unset DISCORD_BOT_TOKEN
apic discord.all > discord.ndjson
apic discord.all GUILD=SERVER_ID SINCE=2026-10-01 > recent.ndjson
apic discord.all CHANNELS=CHANNEL_ID,THREAD_ID > selected.ndjson
apic discord.watch
apic discord.watch CATCH_UP=true
apic discord.logoutEnable User Settings → Advanced → Developer Mode in Discord to copy server,
channel, and thread IDs from their context menus. GUILD accepts one numeric
server ID; CHANNELS is an exact ID allowlist. For forum/media channels, omit
CHANNELS to discover posts, or pass individual thread IDs. Settings may also
specify guild and channels in ~/.apicat-settings/discord.json.
A bot cannot read your personal conversations with other people. DMs sent directly
to the bot can be read with an explicit channel ID. Watch records discovered DM
channel IDs so later unfiltered exports can include them; there is no bot endpoint
to enumerate all historical DMs. A GUILD filter excludes DMs.
The token uses macOS Keychain when available; the file fallback is
~/.apicat-settings/discord-credentials.json (0600). All settings, known DM IDs,
watch state, and export checkpoints stay under ~/.apicat-settings/ (0700).
Bot tokens do not refresh: after resetting one in Discord, supply the replacement.
Logout removes the local token only; stop running watchers separately.
Exports paginate history and respect rate-limit retry delays. RESUME=true resumes
the same selection and SINCE; append to the original file. An interrupted page
can repeat. Output records contain guild, channel, channel_name, and message.
Attachments remain metadata/URLs. Permission/API failures stop the export rather
than silently treating an inaccessible conversation as empty.
Watch requests the Gateway intents automatically (no separate event subscription
screen). It sends heartbeats, reconnects, resumes live sessions, filters selected
guilds/channels, and remembers recent event hashes to suppress replays. Its records
contain source: "event", guild, channel, event_type, and event. Updates and
deletions may be partial objects. --debug prints types/filter decisions without
message bodies or credentials. Ctrl+C or cancellation of the JavaScript Response
body stops the listener. Only one watcher per bot is supported; sharded bots are
outside this command's scope.
CATCH_UP=true reads retained history when a fresh Gateway session is needed after
a restart or failed resumption, tagging recovered records source: "history".
History can overlap live events and cannot reconstruct deletions or all edits;
deduplicate by channel and message ID. Delivery is best-effort. After a hard crash,
remove stale Discord lock directories only after checking no matching process is
running. Complete setup instructions ship in apic discord.all --help and
apic discord.watch --help.
