A CLI client for Tidewave MCP servers. Every Tidewave tool becomes a shell subcommand, so any agent that can run a command can introspect your running Elixir application — no MCP client, no JSON-RPC by hand, and no HTTP port if you would rather use a unix socket.
$ brew install elepedus/tap/tw
$ tw --url http://localhost:4000 eval "1 + 1"
2That is the whole setup. The rest of this file is detail.
$ brew install elepedus/tap/tw # macOS
$ go install github.com/elepedus/tw@latest # anywhere with GoOr take a binary for your platform from the
releases page — tw is a single file
with no runtime dependencies, so putting it on your PATH is the whole install.
Repeating --url gets old. Drop a .tidewave file at the root of your project:
socket = /tmp/myapp.sockNow tw finds the server from anywhere in the project:
$ tw eval "MyApp.Repo.aggregate(MyApp.Accounts.User, :count)"
42tw looks for the connection in this order, and the first answer wins:
--socket PATHor--url URL.tidewavein the current directory or any ancestorTW_SOCKETorTW_URL/tmp/tidewave.sock
Steps 3 and 4 apply only when there is no .tidewave at all. A file that exists
but cannot be used stops the search and says why, rather than letting tw connect
somewhere you did not ask for — see
When the config is wrong.
A [section] names another way to reach a server. Everything above the first
section is the default target, and --target picks any other:
# The app's own socket.
socket = /tmp/myapp.sock
# The same app reached through the dev proxy, for when you are working behind it.
[proxy]
url = http://myapp.localhost:4000$ tw logs --tail 20 # default: the socket
$ tw --target proxy logs --tail 20 # through the proxyGive a target either a socket or a URL. Set both and the socket wins with nothing
said about it, so the URL looks used when it is not. tw does not fail over
between targets either — --target is how you move.
Because resolution walks up from the working directory, a file above several projects can name all of their servers. Leave it sectioned, with no default:
# ~/code/.tidewave
[api]
socket = /tmp/api.sock
[web]
socket = /tmp/web.sock$ tw --target web schemasEvery call from anywhere under ~/code then has to say which server it means:
$ tw schemas
error: /Users/me/code/.tidewave: no default target
every setting is under a section, so pass --target with one of: api, web — …That is the point of leaving out a default rather than a shortcoming. A default
here would mean that standing in api's directory and running bare tw silently
reached whichever server the default named — quite possibly web's.
If you would rather bare tw just worked, that is the argument for a .tidewave
per project instead, each with a default. The nearest file wins outright and the
two never merge, so a file inside a project fully replaces this one.
It resembles INI and TOML without being either — a flat key = value parser, two
keys, and sections:
socketa unix socket path,urlan HTTP(S) base URL. Nothing else is read; unknown keys are ignored rather than refused.- One or the other per target. Set both and the socket wins with nothing said, so the URL looks used when it is not.
- Quotes are optional and stripped.
socket = /tmp/a.sockandsocket = "/tmp/a.sock"are the same thing. #comments and blank lines are skipped, but only on a line of their own. A comment after a value becomes part of the value.- Keys above the first
[section]are thedefaulttarget, which is what baretwuses. A file made entirely of sections has no default, so baretwthere asks you to pass--targetrather than guessing. - A
[section]is reachable only as--target section.
Generating one per worktree is a good use of a create hook — derive the socket from whatever your proxy actually resolves rather than rebuilding the name, or the two drift.
Each worktree is its own directory, so each one holds its own .tidewave and
nothing needs naming or switching:
# ~/code/myapp/.tidewave
socket = /tmp/myapp.sock# ~/code/myapp-feature-x/.tidewave
socket = /tmp/myapp-feature-x.socktw eval then reaches whichever worktree's server you are standing in, which is
usually what you meant.
$ tw --help| Command | Tidewave tool | Notes |
|---|---|---|
tw eval <code> |
project_eval |
|
tw logs |
get_logs |
|
tw source <ref> |
get_source_location |
|
tw docs <ref> |
get_docs |
|
tw schemas |
get_ecto_schemas |
needs :ecto_repos |
tw sql <query> |
execute_sql_query |
needs :ecto_repos |
tw ash |
get_ash_resources |
needs Ash |
tw browser <action> |
browser_eval |
needs an open browser |
The Notes column is where a command can be unavailable through no fault of tw:
Tidewave registers schemas, sql, and ash only under those conditions, and
browser needs something on the other end. See
Server-side requirements.
$ tw eval ":erlang.system_info(:process_count)"
418The result comes back as Elixir would inspect it.
--arg passes values the code reads as arguments, in order:
$ tw eval "Enum.sum(arguments)" --arg 1 --arg 41
42Each value is read as JSON when it parses as one, so --arg 1 is the integer 1
and --arg true is a boolean. Anything else is a string, which is what an
unquoted word means on a command line. To force a string that looks like a
number, quote it for JSON as well as for the shell: --arg '"1"'.
--timeout MS overrides the 30s the tool allows itself. tw waits longer than
whatever you set, so a slow evaluation is reported by the server rather than cut
off here.
$ tw logs --tail 100 --grep timeout --level error--tail defaults to 20. --level takes one of emergency, alert,
critical, error, warning, notice, info, debug.
$ tw source MyApp.Accounts.create_user/1
lib/my_app/accounts.ex:112
$ tw docs Enum.sum/1
# Enum.sum/1
```elixir
sum(enumerable)
```
Returns the sum of all elements.Both take a module, Module.function, or Module.function/arity. docs also
takes callbacks as c:GenServer.init, and source takes dependencies as
dep:tidewave. source covers your project and its dependencies, not Elixir's
own core modules.
$ tw schemas
* MyApp.Accounts.User at lib/my_app/accounts/user.ex:1
* MyApp.Blog.Post at lib/my_app/blog/post.ex:1
$ tw sql "SELECT id, name FROM users"
%Exqlite.Result{
command: :execute,
columns: ["id", "name"],
rows: [[1, "Alice"], [2, "Bob"]],
num_rows: 2
}The result is your adapter's own struct, inspected — %Postgrex.Result{} against
Postgres, and so on.
--arg binds query parameters positionally, with the same JSON reading as
eval, so a numeric parameter matches an integer column:
$ tw sql "SELECT name FROM users WHERE id = $1" --arg 2Use whichever placeholder syntax your adapter wants — $1 for Postgres, ?1
for SQLite. --repo picks a repository when the application has more than one;
without it Tidewave uses the first.
Results are capped server-side, and a query returning more rows than the cap says so in its output.
A query can start with --, which the shell hands over as if it were a flag.
Put -- first to end flag parsing:
$ tw sql -- "-- a comment
SELECT 1"Drives a browser attached to Tidewave's control page at <your-app>/tidewave.
The actions belong to the browser rather than to Tidewave, so ask it:
$ tw browser help--arg KEY=VALUE builds the action's parameters, values read as JSON as
elsewhere. --sid targets one session when several are open:
$ tw browser navigate --arg url=/users --sid nice-cactus#1Tidewave waits on a named session indefinitely, so tw sets no deadline of its
own for a --sid call. Interrupt it, or bound it with --wait.
An MCP server announces itself to an agent. A CLI does not, and tw is too new to
be in any model's training data, so an agent has no reason to reach for it. The
fix is a skill, which ships inside the binary:
$ tw skill install
claude installed at ~/.claude/skills/tw/SKILL.md
codex installed at ~/.codex/skills/tw/SKILL.md
pi installed at ~/.pi/agent/skills/tw/SKILL.md
Agents pick this up straight away. tw keeps it up to date from here on.All three read their skills directory per session rather than caching it, so nothing needs restarting.
There is nothing else to install and nothing to keep in step by hand. From then
on tw maintains those files itself: when the binary is upgraded and the skill
text has changed with it, the next agent-driven run rewrites every installed copy
and says so on stderr — wherever it is called from, so upgrading does not mean
running tw once inside each harness.
The skill is deliberately thin. It carries the part --help cannot — when to
prefer tw eval over a shell one-liner, or tw source over grep — and points at
tw --help for everything else, so it cannot drift out of step with your binary.
tw skill status reports every harness and exits non-zero on drift.
tw skill uninstall removes it and stops tw offering it again.
| Harness | Skill path | Detected by |
|---|---|---|
| Claude Code | ~/.claude/skills/tw/ |
CLAUDECODE |
| Codex | $CODEX_HOME/skills/tw/, default ~/.codex |
CODEX_THREAD_ID |
| pi | ~/.pi/agent/skills/tw/ |
PI_CODING_AGENT |
install covers every harness whose config directory already exists, so it will
not create one for a tool you do not have. --agent claude|codex|pi narrows it to
one, and --agent all insists on the lot.
One SKILL.md serves all three. Codex checks frontmatter against a fixed
allowlist — name, description, license, allowed-tools, metadata — and
rejects the file outright on anything else, so the narrowest harness sets the
ceiling and a test enforces it.
tw ships the digest of every version of the skill it has ever published. A file
whose digest is on that list is one tw wrote, so replacing it discards nothing
you chose. A file whose digest is not on the list is yours: tw leaves it exactly
as it is, says so once per session, and tw skill install refuses until you pass
--force.
tw does not merge. If you want to keep your changes and take the new version,
diff the two yourself — tw skill status prints the path.
That list lives in skill_hashes.go and is generated, not written:
$ go generate ./...It has to be exhaustive, because a missing digest makes tw disown a file it
published and stop maintaining it on every machine still running that version — a
failure that never shows up locally. So it is derived from git history, which
already records every version of skill/SKILL.md, and two tests keep it honest:
one fails if the embedded skill has no digest, the other rebuilds the list from
git and fails if any committed version is missing from it. The second is the one
that catches a digest being deleted, which is otherwise invisible.
The upkeep and the notes happen only when one of the markers above is set, so a person at a terminal never has a file under a harness config change as a side effect of reading logs. Notes go to stderr and appear at most once per session, so stdout stays exactly what the tool returned.
A skill that is missing rather than out of date is only ever mentioned for the
harness actually running tw, so a machine with all three does not produce two
notes about the tools you never point it at.
The tool's text goes to stdout with nothing added, so it pipes. Errors and diagnostics go to stderr.
| Exit | Meaning |
|---|---|
| 0 | success |
| 1 | the server ran the tool and it failed |
| 2 | bad usage, or no answer from the server |
--json prints the whole JSON-RPC response instead of the text:
$ tw --json eval "1 + 1"
{"id":1,"result":{"content":[{"type":"text","text":"2"}]},"jsonrpc":"2.0"}A call is bounded at 60s by default. Commands raise that when they know the
server is entitled to take longer: eval outlasts its own --timeout, and
browser --sid is not bounded at all.
--wait SECONDS overrides all of it, and --wait 0 waits indefinitely:
$ tw --wait 300 sql "SELECT * FROM a_very_large_table"execute_sql_query has no server-side limit of its own, so --wait is the only
way to allow a query past the default.
When the wait runs out, the call is abandoned but the tool is not: it keeps
running on the server, and tw says so rather than reporting a bare deadline.
tw speaks to Tidewave, so anything Tidewave will not do is out of its hands.
Tested against Tidewave 0.8.1.
Four tools are conditional, and asking for one the server has not registered
fails with server error: Method not found:
schemasandsqlneed the host application to declare:ecto_repos.ashneeds Ash to be loaded.browserneeds a browser open on Tidewave's control page, otherwise it reports that nothing is connected.
Connecting over a unix socket needs allow_remote_access: true on the Tidewave
plug. Tidewave's guard accepts only loopback IPv4/IPv6 peers, and a unix-domain
peer has no address to match, so it is refused despite being strictly more local
than a TCP connection. Gate access with filesystem permissions on the socket
instead. Over HTTP on loopback, no such setting is needed.
$ go test ./...The tests run against a real Elixir server rather than a mock. It is built and
started for you if mix is on PATH; if not, those tests skip and the rest
still run. See testserver/README.md.
A .tidewave file tw can see is authoritative: rather than passing over one it
cannot use and connecting somewhere else, tw names the file and says what is
wrong with it.
$ tw eval "1 + 1"
error: /Users/me/code/.tidewave: no target named "dv"
available: dev, liveIt covers an empty file, a file of only comments, a misspelt key, a file whose
targets are all sectioned so bare tw has no default, a --target that is not
there, and a target that names neither socket nor url. The path matters most —
the file may sit in an ancestor directory you have forgotten about.
An explicit --socket or --url is checked before the file is even looked for,
so a broken config never blocks your way past it. A file that is absent is not
the same as one that is broken: with no file at all, TW_SOCKET/TW_URL and the
default socket still apply as usual.
MIT.