Guide

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.

TierWhat you getWhat you needCode shape
1. Hosting + access controlCloud Run, gateway-enforced access (public / private / team), env-var injectionCLI only — any languageYour language's standard env-var API
2. IntegrationsGmail / Calendar / Drive (typed) + Linear / HubSpot / Slack / GitHub / etc. via the generic MCP escape hatch@leash/sdk + org API key + integration grantsawait leash.integrations.gmail.listMessages(...)
3. Dynamic env varsOn-demand fetch, key rotation without redeploy, audit trailGrowth+ plan, same SDKawait leash.env.get('STRIPE_KEY')
4. Identity in codeKnow who is calling your handler — id, email, name, pictureSame SDKconst 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.

Terminal

$ 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:

  1. Sign up for Leash at leash.build. Free tier covers most local dev.
  2. Install the CLI and sign in:
    Terminal

    $ curl -fsSL https://leash.build/install.sh | sh

    $ leash login

  3. Bootstrap your app:
    Terminal

    $ cd my-app

    $ leash init --name my-app # writes .leash/config.json

  4. 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

Terminal

$ 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

app/api/my-route/route.ts
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_KEY from env
  • Sends the call through Leash's proxy → Gmail
  • Returns typed data

Available typed integrations today

ProviderMethods
leash.integrations.gmaillistMessages, getMessage, sendMessage, searchMessages, listLabels, getProfile
leash.integrations.calendarlistCalendars, listEvents, createEvent, getEvent
leash.integrations.drivelistFiles, 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 providers
const issues = await leash.integrations.mcp('linear', 'list-issues', {
team: 'eng',
})
// REST-style custom providers
const 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 mechanismRotation behavior
process.env.XBake-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 bearerPer-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.)

app/api/me/route.ts
import { Leash } from '@leash/sdk/leash'
export async function GET(req: Request) {
const leash = new Leash({ request: req })
const user = leash.auth.user() // LeashUser | null
if (!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

Terminal

$ leash deploy

What changes between local and deployed

LocalDeployed
leash-auth cookielocalhost, via /api/leash/dev-auth*.un.leash.build auto-sent
LEASH_API_KEYIn your .env.localAuto-injected
Dashboard secretsPulled into .env.local declared keysMounted on the Cloud Run revision
leash.env.get(...)Fetches at runtime same as prodSame

Same code runs on both sides.

Errors — common codes

CodeMeaningAction
NO_API_KEYLEASH_API_KEY missingSet in .env.local
NO_REQUEST_SERVER_CONSTRUCTConstructed without request in a server contextPass { request: req }
BROWSER_MODE_UNSUPPORTEDTried to new Leash() in the browserConstruct server-side only
UNAUTHORIZEDBad API key or expired cookieRe-mint key / re-sign-in
INTEGRATION_NOT_ENABLEDApp not granted this integrationGrant at /dashboard/connections
INTEGRATION_ERRORUpstream provider returned an errorSee seeAlso for provider docs
UPGRADE_REQUIREDFeature gated to a higher planUpgrade at /dashboard/billing
KEY_NOT_DECLAREDEnv key not in .env.exampleAdd it
INVALID_KEYEnv 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.