Building with Leash
This is the developer guide for the Leash platform. Tier 1 (hosting + access control) is language-agnostic — deploy any framework that ships an HTTP server. Tiers 2–4 use @leash/sdk, which ships as a TypeScript client today (other languages are in progress). It's tiered: you opt into surface as you need it. Most apps stop at Tier 2.
TL;DR
Sign up once, install once, leash init once. After that, your local dev loop is sub-second and identical to production. leash deploy is for shipping, not iterating.
The tiered model
The SDK is opt-in. You can deploy an app to Leash without installing the SDK at all. Each tier above adds capability without forcing you to adopt the others.
| Tier | What you get | What you need | Code shape |
|---|---|---|---|
| 1. Hosting + access control | Cloud Run, gateway-enforced access (public / private / team), env-var injection | CLI only — any language | Your language's standard env-var API |
| 2. Integrations | Gmail / Calendar / Drive (typed) + Linear / HubSpot / Slack / GitHub / etc. via the generic MCP escape hatch | @leash/sdk + org API key + integration grants | await leash.integrations.gmail.listMessages(...) |
| 3. Dynamic env vars | On-demand fetch, key rotation without redeploy, audit trail | Growth+ plan, same SDK | await leash.env.get('STRIPE_KEY') |
| 4. Identity in code | Know who is calling your handler — id, email, name, picture | Same SDK | const user = leash.auth.user() |
Important
Tier 4 is about identity inside your code. Restricting access to your app (e.g. “only my org can hit this URL”) happens at Tier 1 via the dashboard — no code required. See the next section.
A Tier 1 app doesn't install the SDK at all — any language, any framework, just deploy. A Tier 2 app pulls in @leash/sdk (TypeScript today).
Tier 1 — Hosting + access control
The simplest case. Your app has its own logic, reads its own env vars, runs on whatever framework. Leash gives you hosting + auth at the gateway.
$ leash deploy
Your app is live at https://my-app-abc123.un.leash.build (the suffix is auto-generated and immutable after first deploy).
Access control (no code)
At /dashboard/apps/<id>/access you choose:
- Public — anyone with the URL (no sign-in needed)
- Private — only the app owner
- Team — only members of your Leash organization
The Leash gateway checks the leash-auth cookie on every request before forwarding to your Cloud Run container. Unauthorized requests get redirected to Leash sign-in. Your application code can assume every request is from an authorized member. Full options in Access control.
Env vars (no code)
Set values in /dashboard/apps/<id>/secrets. They're auto-injected at runtime into your container's environment, where you read them with your language's standard env-var API. See App env vars for the full .env.example manifest model.
In your code:
const stripeKey = process.env.STRIPE_SECRET_KEY
If this is all you need, stop here. Most apps stop here.
Before installing the SDK
The SDK calls Leash's platform proxy on every integration and env-var call. To use it — locally or in production — do this once per project:
- Sign up for Leash at leash.build. Free tier covers most local dev.
- Install the CLI and sign in:Terminal
$ curl -fsSL https://leash.build/install.sh | sh
$ leash login
- Bootstrap your app:Terminal
$ cd my-app
$ leash init --name my-app # writes .leash/config.json
- Grant integrations to your org at
/dashboard/connections(one-time per provider, org-wide).
There is no offline / mock mode
Every SDK call goes through leash.build, so a Leash account is required regardless of where you run.
Tier 2 — Integrations
Call Gmail / Calendar / Drive (and any provider Leash has typed) without managing OAuth yourself. The SDK ships as a TypeScript client today — other languages are in progress.
Install
$ npm install @leash/sdk
Mint an org API key
Visit https://leash.build/dashboard/organization → API Keys → create. Save the lsk_live_* value to your .env.local:
LEASH_API_KEY=lsk_live_...
In production this is auto-injected by leash deploy — you only need this step locally.
Make your first call
import { Leash } from '@leash/sdk/leash'export async function GET(req: Request) {const leash = new Leash({ request: req })const result = await leash.integrations.gmail.listMessages({query: 'newer_than:1d',maxResults: 10,})return Response.json(result)}
That's it. The Leash class:
- Detects you're in server context (because you passed
request) - Reads
LEASH_API_KEYfrom env - Sends the call through Leash's proxy → Gmail
- Returns typed data
Available typed integrations today
| Provider | Methods |
|---|---|
leash.integrations.gmail | listMessages, getMessage, sendMessage, searchMessages, listLabels, getProfile |
leash.integrations.calendar | listCalendars, listEvents, createEvent, getEvent |
leash.integrations.drive | listFiles, getFile, downloadFile, createFolder, uploadFile, deleteFile, searchFiles |
Other providers (generic escape hatch)
For Linear, HubSpot, Slack, Jira, Gong, BigQuery, Slite, GitHub, etc., use the generic MCP escape hatch:
// MCP-backed providersconst issues = await leash.integrations.mcp('linear', 'list-issues', {team: 'eng',})// REST-style custom providersconst result = await leash.integrations.integration('hubspot').call('/contacts',{ method: 'GET' },)
Error handling
Every SDK error is a LeashError with an action you can act on:
import { Leash, LeashError } from '@leash/sdk/leash'try {await leash.integrations.gmail.listMessages(...)} catch (err) {if (err instanceof LeashError) {console.error(err.toString())// × Integration "gmail" is not enabled for this app.// Fix: Connect "gmail" at https://leash.build/dashboard/connections// and add this app to its allow-list.// See: https://leash.build/docs/integrations/gmail}}
Tier 3 — Dynamic env vars
Most apps read secrets from the process environment at startup (process.env in Node, os.environ in Python, etc.). That's fine for static values injected at deploy time. The TypeScript SDK's env namespace adds value for cases the static env can't handle:
const stripeKey = await leash.env.get('STRIPE_SECRET_KEY')// Force-fresh — bypasses the 60s cache.const fresh = await leash.env.get('STRIPE_SECRET_KEY', { fresh: true })// Bulk fetch.const { OPENAI_API_KEY, STRIPE_KEY } = await leash.env.getMany(['OPENAI_API_KEY','STRIPE_KEY',])
How rotation behaves
Rotating a value in your source updates Leash immediately, but when running app code sees the new value depends on how it reads the secret:
| Read mechanism | Rotation behavior |
|---|---|
process.env.X | Bake-time. The value is mounted into the Cloud Run revision at deploy. Re-deploy (or hit “sync now”) to pick up a new value. |
await leash.env.get('X') | Runtime fetch. The next call after rotation gets the new value (per-instance cache, ~60s); { fresh: true } bypasses the cache. |
| Custom MCP bearer | Per-call resolve. The token is fetched fresh via getCustomMcpConfig on every call — picks up the new value on the next call. |
For static values that never rotate (e.g. a feature-flag key), keep reading them from your process environment. For anything you'd want to rotate without redeploying — or to get an audit trail of every read — use leash.env.get (TypeScript SDK today).
Requires Growth+ plan
The runtime fetch (leash.env.get) is Growth+ regardless of source — even if your values live in the free dashboard-native source. Storing values is still free; the gate is on the SDK fetch surface. The SDK surfaces this clearly with UPGRADE_REQUIRED and a link to billing.
Setup
Declare the keys you'll fetch in your .env.example:
ANTHROPIC_API_KEY=STRIPE_SECRET_KEY=
Set their values once in /dashboard/organization/secrets. Multiple apps in the same org share the source — wire once, reuse everywhere.
Tier 4 — Identity in code
Know who is calling your handler. Use this when you want to scope data per-user, personalize, or log who did what. (You do NOT need this to restrict access — that's Tier 1.)
import { Leash } from '@leash/sdk/leash'export async function GET(req: Request) {const leash = new Leash({ request: req })const user = leash.auth.user() // LeashUser | nullif (!user) {return Response.json({ error: 'unauthorized' }, { status: 401 })}return Response.json({ id: user.id, email: user.email, name: user.name })}
leash.auth.user() is sync and null-returning — no try/catch.
Local dev requires a two-line handler
The leash-auth cookie isn't sent to localhost by default. Wire Leash.createDevAuthHandler() at /api/leash/dev-auth once per project and use the dashboard's “Open in local dev” button to seed it. Full steps in Local dev.
Production deploy
$ leash deploy
What changes between local and deployed
| Local | Deployed | |
|---|---|---|
leash-auth cookie | localhost, via /api/leash/dev-auth | *.un.leash.build auto-sent |
LEASH_API_KEY | In your .env.local | Auto-injected |
| Dashboard secrets | Pulled into .env.local declared keys | Mounted on the Cloud Run revision |
leash.env.get(...) | Fetches at runtime same as prod | Same |
Same code runs on both sides.
Errors — common codes
| Code | Meaning | Action |
|---|---|---|
NO_API_KEY | LEASH_API_KEY missing | Set in .env.local |
NO_REQUEST_SERVER_CONSTRUCT | Constructed without request in a server context | Pass { request: req } |
BROWSER_MODE_UNSUPPORTED | Tried to new Leash() in the browser | Construct server-side only |
UNAUTHORIZED | Bad API key or expired cookie | Re-mint key / re-sign-in |
INTEGRATION_NOT_ENABLED | App not granted this integration | Grant at /dashboard/connections |
INTEGRATION_ERROR | Upstream provider returned an error | See seeAlso for provider docs |
UPGRADE_REQUIRED | Feature gated to a higher plan | Upgrade at /dashboard/billing |
KEY_NOT_DECLARED | Env key not in .env.example | Add it |
INVALID_KEY | Env key name fails /^[A-Za-z_][A-Za-z0-9_]*$/ | Rename |
See Error handling for the full code list and retry patterns, and the SDK API reference for namespace-by-namespace signatures.