A cloud for terminal apps. Write a CLI or TUI in Go against the Plumtree
SDK, deploy it with one command, and anyone runs it with just ssh — no install
of any kind. Plumtree compiles each app to WebAssembly, runs it sandboxed on its
own servers, and streams the rendered terminal to the user.
Think Lakebed, but for the terminal — instead of full-stack web capsules, Plumtree hosts SDK-written terminal apps, rendered server-side and streamed over SSH.
# run any deployed app — nothing to install but ssh
ssh <owner>/<app>@plumtree.app
# ship your own
pt new myapp --tui
pt dev
pt deploy
A Plumtree app is a small Go program written against the SDK. You never touch
raw os, net, or ANSI — the app reaches the outside world only through a
capability object (ctx) the platform hands it.
- Author —
pt newscaffolds the standard app shape;pt devcompiles to WASM and runs it locally in wazero over a real PTY. - Deploy —
pt deployuploads source; the platform builds it to WASM in a sandboxed build worker and stores the artifact. - Run — a user connects with plain
ssh. The platform instantiates the WASM module in an isolated runner, bridges keystrokes in and rendered frames out, and streams it over the SSH PTY.
The connecting user runs nothing locally — only ssh and a terminal. The
app's code never reaches their machine, so a malicious app can't touch their
files, env, or disk. The execution risk lives entirely with the platform, which
is why every app is sandboxed by default.
author ── pt deploy ──▶ control-plane ──▶ build-worker (Go ─▶ WASM)
│
user ── ssh ──▶ ssh-gateway ──▶ runner (wazero sandbox) ──▶ your app
│
ctx: kv · pubsub · auth · env · fetch
A TUI is state → build a component tree → the runtime lays it out and diff-renders it to a cell grid. The app returns structured cells; the host turns them into terminal output (so apps can never emit raw escape codes).
package main
import (
"fmt"
"github.com/Ceinl/plumtree/sdk"
"github.com/Ceinl/plumtree/sdk/tui"
"github.com/Ceinl/plumtree/sdk/tui/components"
)
type state struct{ n int }
func (s *state) Update(ev sdk.Event) {
if k, ok := ev.(sdk.KeyMsg); ok {
switch k.Key {
case sdk.KeyUp:
s.n++
case sdk.KeyDown:
s.n--
case 'q':
sdk.Quit()
}
}
}
func (s *state) View() tui.Component {
root := components.NewDiv()
root.SetDirection(tui.Column)
root.SetJustify(tui.JCenter)
root.SetAlign(tui.ACenter)
count := components.NewText()
count.SetContent(fmt.Sprintf("Count: %d", s.n))
hint := components.NewText()
hint.SetContent("(↑/↓ to change, q to quit)")
root.Add(count, hint)
return root
}
func main() { sdk.RunTUI(&state{}, sdk.Meta{Name: "counter", Type: "tui"}) }…or a non-interactive CLI:
func main() {
sdk.CLI(sdk.Meta{Name: "hello", Type: "cli"},
func(ctx sdk.Ctx, args []string) error {
name := "world"
if len(args) > 0 {
name = args[0]
}
ctx.Out().Printf("Hello %s\n", name)
return nil
})
}app/main.go # entrypoint: the CLI/TUI definition
go.mod
plumtree.json # committed: { "deployId": "..." } once claimed
.env.plumtree.server.local # optional, gitignored; secrets live server-side
The app touches the world only through host functions imported into the WASM guest. More trust unlocks more capability:
| Capability | What it gives | Availability |
|---|---|---|
ctx.KV |
durable per-app key/value state | all apps |
| pub/sub | live cross-session messaging (no poll) | all apps |
ctx.Auth |
proved SSH-key or explicit anonymous identity | all apps |
ctx.Env |
server-side secrets | claimed apps |
ctx.Fetch |
gated, default-deny egress allowlist | claimed apps |
pt is the author tool — scaffold, dev-run, deploy, inspect. It is not
needed to run apps (that's ssh).
pt new <name> --tui|--cli # scaffold the standard Go app shape
pt dev # compile to WASM + run locally in wazero
pt dev --ssh # serve the local app over a local SSH channel
pt deploy # build server-side + deploy
pt claim # browser-claim the deploy to your owner (this is author auth)
pt whoami # show your claimed namespace
pt secret set KEY # server-side secret (claimed apps)
pt egress add HOST # egress allowlist entry (claimed apps)
pt logs <app> # session logs
pt inspect <deploy|handle> # deploy details
Author auth is the deploy claim, not a separate login: pt claim opens a
Shoo browser flow that binds the deploy to your owner. Possession of the claim
token (in .plumtree/deploy.json) authorizes later updates, secrets, and egress.
RCE is the product, not a bug — every app is hostile by default, so the goal is containment, not prevention.
- WASM/wazero is the primary boundary. Each app runs as a WASI reactor with no ambient filesystem, env, args, or network — it can only call the host functions we import. Production runners are separate worker processes from the control plane.
- Progressive trust = capability. Unclaimed apps run in the tightest
sandbox (KV only, no secrets, no egress). Claiming unlocks
ctx.Envand gatedctx.Fetch. - No raw-ANSI escape path. The guest returns structured cells (
rune + RGB + decor); the host renders them and sanitizes every rune, so apps can't attack the viewer's terminal. - Build is sandboxed too. Compiling untrusted Go runs code before run-time, so builds happen in isolated workers — no secrets, no default network, bounded CPU/memory/disk, isolated module cache, checksum + module policy enforcement.
- Hard limits everywhere — per-frame wall-clock deadlines, memory page caps, output/input rate, storage quotas, per-author concurrency caps, deploy rate limits, and kill switches. Deploy is gated harder than run.
SSH secures the channel only — it does not protect data at rest, from other tenants, or from the operator.
A multi-module Go workspace (go.work), split by product boundary:
| Path | Module | Purpose |
|---|---|---|
tui-runtime/ |
github.com/Ceinl/plumtree/tui-runtime |
Standalone TUI runtime: layout, components, screen/diff. |
sdk/ |
github.com/Ceinl/plumtree/sdk |
Author-facing Go SDK and the versioned WASM ABI wrapper. |
pt/ |
github.com/Ceinl/plumtree/pt |
Author CLI: scaffold, dev, deploy, claim, logs, secrets. |
control-plane/ |
github.com/Ceinl/plumtree/control-plane |
Platform API: app/deploy metadata, auth, tokens, quotas. |
build-worker/ |
github.com/Ceinl/plumtree/build-worker |
Sandboxed source-to-WASM build service. |
runner/ |
github.com/Ceinl/plumtree/runner |
Isolated WASM session runner + host capability implementation. |
ssh-gateway/ |
github.com/Ceinl/plumtree/ssh-gateway |
SSH front end mapping connections to deployed app sessions. |
The end-to-end author loop works against a local control plane:
pt new → pt dev → pt dev --ssh → pt deploy → pt claim → ssh <app>@plumtree.dev
A deployed app is built server-side from uploaded source, stored as a WASM
artifact, and streamed over SSH; every session runs in its own wazero sandbox
with no ambient authority. The full host-capability surface — KV, pub/sub, auth,
env/secrets, and gated fetch — is wired end to end on a shared host-import +
ptr/len ABI, with native + wasip1 builds from one source and e2e tests that
build the real WASM guest.
Production hardening is in place: out-of-process runner isolation, durable artifact storage, an out-of-process build worker, deploy-rate limiting, an anonymous preview mode, and the SSH gateway extracted into its own deployable binary.
Next up: moderation & per-author quotas at scale, richer scoped storage
(ctx.DB), and content-addressed artifact caching on the gateway.
Deferred: fully anonymous public deploy, native binaries / microVMs, non-Go languages, teams/orgs, billing, custom handles, and compatibility with arbitrary existing terminal apps.
- Plumtree / pt — the platform and its author CLI.
- App — an SDK-written Go terminal program, namespaced
<owner>/<app>, compiled to WASM. - ctx — the capability object (host functions) handed to an app: kv, pubsub, auth, env, fetch, io.
- Sandbox — the wazero WASM instance an app runs in, server-side.
- Claim — authenticating ownership to unlock higher-trust capabilities.
- Deploy — publish an app via
pt(the privileged author action). - Run — connect to an app with plain
ssh; the platform executes it.