---
title: Quick start
summary: Boot a local FastYoke backend (npx or Docker), provision a deal-tracker FSM, and wire up a React app with the SDK — no account needed.
order: 1
---

# Quick start

This walkthrough takes you from zero to a running React app backed by a
real FastYoke workflow, entirely on your own machine — no sign-up, no
cloud tenant. You'll build a tiny "deal tracker": a list of deals, each
one a job moving through a three-state pipeline (`New` → `Working` →
`Won`), driven by the FastYoke FSM engine and rendered with
`@fastyoke/sdk` React hooks.

## What you'll build

A single-page app that lists a handful of deals and shows each one's
current pipeline state next to a button for the next valid transition.
Clicking **Advance** moves a deal from `New` to `Working`; clicking
**Win** moves it from `Working` to `Won`. Every click is a real FSM
transition against a local backend — there's no mock data layer.

Along the way you'll touch the three primitives every FastYoke app is
built from:

- **Entities** — your domain records (here, a `deal` with a `name` and
  an `amount`), stored and fetched through the tenant entity API.
- **FSM jobs** — a `deal_lifecycle` schema with two transitions
  (`advance`, `win`) driving each deal's job through its states.
- **The SDK** — `@fastyoke/sdk`'s React hooks (`useEntities`, `useJobs`,
  `useTransitionJob`) that read and drive both of the above.

You have two ways to run the backend locally — pick one:

- **npx runtime (light)** — a zero-install Node sidecar. Fastest to try,
  but it's a subset of the platform (no PDF, scripting, extensions,
  e-signature, or marketplace apps).
- **Docker full engine** — the complete engine in one container. Same
  FSM/entities/forms core, plus everything the light runtime leaves out.

The steps below are identical in shape for both, but the exact commands
diverge until you get to the React app, which is one shared codebase for
either backend.

## Prerequisites

- **Node.js 18+** and `npm`.
- **Docker** — only if you're taking the Docker full-engine path. The
  npx path needs nothing beyond Node.
- No FastYoke account, sign-up, or API key required for either path.

## Step 1 — Start a local backend

Pick one.

### Option A: npx runtime (light)

```bash
npx --yes fastyoke@latest init
npx --yes fastyoke@latest dev
```

`init` scaffolds a `.fastyoke/` directory (config, an auth key, a seed
file, and uploads) in your current directory. `dev` boots the sidecar
against an on-disk SQLite database:

```
Server listening at http://127.0.0.1:8787
```

This is a **subset** of the platform — no PDF rendering, WASM scripting
tier, extensions, e-signature, or marketplace apps. It covers the
FSM/entities/forms core, which is everything this walkthrough needs.

Base URL for this path: `http://127.0.0.1:8787`.

### Option B: Docker full engine

```bash
docker run -d --name fy-qs -p 8080:8080 -e DEPLOY_ENV=sandbox -e PORT=8080 ghcr.io/fastyoke/backend:sandbox
```

`DEPLOY_ENV=sandbox` runs against a plain SQLite database with
third-party integrations (payments, email, external SSO) pointed at
local stubs, so nothing leaves your machine. Give it a second to come
up, then confirm it's healthy:

```bash
until curl -sf http://127.0.0.1:8080/api/v1/health >/dev/null; do sleep 1; done
curl -s http://127.0.0.1:8080/api/v1/health
# {"service":"fastyoke","status":"ok"}
```

Base URL for this path: `http://127.0.0.1:8080`.

## Step 2 — Get a token and tenant id

How you get an admin token and a tenant id depends on which backend you
started.

### Option A: npx runtime

There's no sign-up call at all — `init`/`dev` already wrote a token to
disk, and the tenant id is always the fixed string `local`:

```bash
cat .fastyoke/token
```

```bash
export FASTYOKE_API_URL=http://127.0.0.1:8787
export FASTYOKE_TOKEN=$(cat .fastyoke/token)
export FASTYOKE_TENANT_ID=local
```

### Option B: Docker full engine

Sign up against the sandbox backend — `turnstile_token: "sandbox"` works
because the sandbox build runs an accept-all Turnstile verifier:

```bash
curl -s -X POST http://127.0.0.1:8080/api/v1/auth/signup \
  -H 'content-type: application/json' \
  -d '{"email":"dev@local.test","password":"DemoPassword123!","org_name":"Deal Co","org_legal_name":"Deal Co","country":"US","turnstile_token":"sandbox"}'
```

The response includes a `jwt` and a `memberships` array — use the first
membership's `tenant_id`:

```json
{
  "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "memberships": [ { "tenant_id": "f91d0a96-7153-4b18-af67-3bd073003640", "...": "..." } ]
}
```

```bash
export FASTYOKE_API_URL=http://127.0.0.1:8080
export FASTYOKE_TOKEN=<the jwt from the response>
export FASTYOKE_TENANT_ID=<memberships[0].tenant_id from the response>
```

## Step 3 — Provision the deal-tracker schema

This is the last step where the two paths differ — everything from here
on is the same code against either backend.

### Option A: npx runtime

There's no schema API on this backend — FSM schemas only come from
`.fastyoke/seed.json`. Open it and merge in a `deal` entity kind, two
sample deal records, the `deal_lifecycle` schema, and two jobs:

```json
{
  "entity_kinds": [
    {
      "name": "deal",
      "schema": {
        "fields": [
          { "key": "name", "type": "string" },
          { "key": "amount", "type": "number" }
        ]
      }
    }
  ],
  "entity_records": {
    "deal": [
      { "id": "deal_acme", "data_payload": { "name": "Acme", "amount": 5000 } },
      { "id": "deal_globex", "data_payload": { "name": "Globex", "amount": 12000 } }
    ]
  },
  "fsm_schemas": [
    {
      "id": "sch-deal-lifecycle",
      "name": "deal_lifecycle",
      "entity_name": "deal",
      "schema_json": {
        "initial_state": "New",
        "transitions": [
          { "event": "advance", "from": "New", "to": "Working" },
          { "event": "win", "from": "Working", "to": "Won" }
        ]
      }
    }
  ],
  "jobs": [
    { "id": "job-deal_acme", "schema_id": "sch-deal-lifecycle", "current_state": "New", "context_record_id": "deal_acme" },
    { "id": "job-deal_globex", "schema_id": "sch-deal-lifecycle", "current_state": "New", "context_record_id": "deal_globex" }
  ]
}
```

Merge these keys into your existing `.fastyoke/seed.json` (add to the
existing arrays/objects rather than replacing the file), then restart
the sidecar to apply it — hot-reload is best-effort, a clean restart
reliably picks up seed edits. Stop the running `dev` process first
(`Ctrl-C` in the terminal where it's running), then start it again:

```bash
npx --yes fastyoke@latest dev
```

::callout{type="warn" title="Heads up"}
Note the schema-authoring key here is `event`, not `event_type`, and
there's no top-level `states` array — this shape is specific to
`seed.json` on the npx runtime. The transition API you'll call from the
React app always uses `event_type`, on both backends.
::

### Option B: Docker full engine

This backend has a real schema API. Save the following as
`provision.mjs` and run it:

```js
// provision.mjs — installs deal_lifecycle + a few New deals. Run: node provision.mjs
const API = process.env.FASTYOKE_API_URL, TOKEN = process.env.FASTYOKE_TOKEN, TENANT = process.env.FASTYOKE_TENANT_ID;
const api = (path, body) => fetch(`${API}${path}`, {
  method: body ? 'POST' : 'GET',
  headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
  body: body ? JSON.stringify({ tenant_id: TENANT, ...body }) : undefined,
}).then(async r => { if (!r.ok) throw new Error(`${path} → ${r.status}: ${await r.text()}`); return r.json(); });

const schema = await api('/api/v1/tenant/schemas', {
  name: 'deal_lifecycle', entity_name: 'deal',
  schema_json: { initial_state: 'New', states: ['New', 'Working', 'Won'],
    transitions: [ { event_type: 'advance', from: 'New', to: 'Working' },
                   { event_type: 'win', from: 'Working', to: 'Won' } ] },
});
for (const d of [{ name: 'Acme', amount: 5000 }, { name: 'Globex', amount: 12000 }]) {
  const rec = await api('/api/v1/tenant/entities/deal', { data_payload: d });
  await api('/api/v1/tenant/jobs', { schema_id: schema.id, context_record_id: rec.id });
}
console.log('provisioned deal_lifecycle + seed deals');
```

```bash
node provision.mjs
```

That's the last forked step — the React app below works unchanged
against whichever backend you provisioned.

## Step 4 — Build the front end

Scaffold a Vite + React + TypeScript app:

```bash
npm create vite@latest deal-tracker-app -- --template react-ts
cd deal-tracker-app
```

::callout{type="warn" title="Heads up"}
`@fastyoke/sdk` peer-depends on React 18, but a fresh `npm create
vite@latest` scaffolds React 19. Pin `react`, `react-dom`,
`@types/react`, and `@types/react-dom` back to `^18.3.x` in
`package.json` **before** installing the SDK below — otherwise the
install fails to resolve peer dependencies.

```json
{
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.31",
    "@types/react-dom": "^18.3.7"
  }
}
```
::

Now install the SDK:

```bash
npm i @fastyoke/sdk @fastyoke/sdk-core
```

Replace `src/App.tsx` with:

```tsx
import { FastYokeProvider, useEntities, useJobs, useTransitionJob } from '@fastyoke/sdk';
import type { EntityResponse, PagedEntityResponse } from '@fastyoke/sdk-core';

const cfg = {
  baseUrl: import.meta.env.VITE_FASTYOKE_API_URL as string,
  tenantId: import.meta.env.VITE_FASTYOKE_TENANT_ID as string,
  token: import.meta.env.VITE_FASTYOKE_TOKEN as string,
};

const fetcher: typeof fetch = (input, init = {}) =>
  fetch(input, {
    ...init,
    headers: { ...init.headers, authorization: `Bearer ${cfg.token}` },
  });

export default function App() {
  return (
    <FastYokeProvider baseUrl={cfg.baseUrl} tenantId={cfg.tenantId} fetcher={fetcher}>
      <DealList />
    </FastYokeProvider>
  );
}

// The npx light-runtime's GET /tenant/entities/:kind returns
// `{ rows, total }`; the Docker full-engine (and the @fastyoke/sdk-core
// `PagedEntityResponse` type) returns `{ records, total, page, page_size }`.
// Read both keys so the same component works against either backend.
function recordsOf(data: PagedEntityResponse | null): EntityResponse[] {
  if (!data) return [];
  const anyData = data as PagedEntityResponse & { rows?: EntityResponse[] };
  return anyData.records ?? anyData.rows ?? [];
}

function DealList() {
  const { data, loading, error } = useEntities('deal', { pageSize: 50 });
  if (loading) return <p>Loading…</p>;
  if (error) return <p>{String(error)}</p>;
  const deals = recordsOf(data);
  return (
    <ul>
      {deals.map((d) => (
        <DealRow key={d.id} deal={d} />
      ))}
    </ul>
  );
}

// The FSM's `event_type` for a given `current_state`. `deal_lifecycle` is
// New -> (advance) -> Working -> (win) -> Won; Won has no outgoing edge.
function nextEvent(state: string): { label: string; eventType: string } | null {
  if (state === 'New') return { label: 'Advance', eventType: 'advance' };
  if (state === 'Working') return { label: 'Win', eventType: 'win' };
  return null;
}

function DealRow({ deal }: { deal: EntityResponse }) {
  // The SDK has no "find job by record id" hook, but `useJobs` accepts
  // `entityId`, which the backend maps to `jobs.context_record_id` — the
  // way to find *this* deal's job. Confirmed on both backends.
  const { data: jobs, loading: jobsLoading, refetch } = useJobs({ entityId: deal.id });
  const { transitionJob, loading: transitioning, error: transitionError } =
    useTransitionJob();
  const job = jobs?.[0];
  const state = job?.current_state ?? '…';
  const next = job ? nextEvent(job.current_state) : null;

  return (
    <li>
      {String(deal.data_payload.name)} — <strong>{jobsLoading ? '…' : state}</strong>
      {next && job && (
        <button
          disabled={transitioning}
          onClick={async () => {
            await transitionJob({ id: job.id, input: { eventType: next.eventType } });
            refetch();
          }}
        >
          {transitioning ? 'Working…' : next.label}
        </button>
      )}
      {transitionError && <span style={{ color: 'crimson' }}> {String(transitionError)}</span>}
    </li>
  );
}
```

Set your environment variables in `.env`:

### Option A: npx runtime

```bash
VITE_FASTYOKE_API_URL=
VITE_FASTYOKE_PROXY_TARGET=http://127.0.0.1:8787
VITE_FASTYOKE_TENANT_ID=local
VITE_FASTYOKE_TOKEN=<contents of .fastyoke/token>
```

### Option B: Docker full engine

```bash
VITE_FASTYOKE_API_URL=http://127.0.0.1:8080
VITE_FASTYOKE_TENANT_ID=<memberships[0].tenant_id from signup>
VITE_FASTYOKE_TOKEN=<jwt from signup>
```

::callout{type="warn" title="Heads up"}
**npx runtime only:** the sidecar sends no CORS headers at all — even
its `OPTIONS` preflight hits the auth middleware and comes back `401` —
so a browser app on a different port can't call it directly with an
`Authorization` header. Leave `VITE_FASTYOKE_API_URL` empty and add a
dev proxy in `vite.config.ts` instead; requests to `/api` are then
same-origin and Vite forwards them to the sidecar. The Docker backend
needs none of this — it sends `access-control-allow-origin: *` and
answers the preflight itself.

Replace `vite.config.ts` with:

```ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  return {
    plugins: [react()],
    server: {
      proxy: {
        '/api': {
          target: env.VITE_FASTYOKE_PROXY_TARGET ?? 'http://127.0.0.1:8787',
          changeOrigin: true,
        },
      },
    },
  };
});
```
::

## Step 5 — Run it

```bash
npm run dev
```

Open the printed local URL in your browser. You'll see the two seeded
deals, Acme and Globex, each showing `New` next to an **Advance**
button. Click it — the button briefly shows "Working…" while the
transition request fires, then the row updates to `Working` and the
button becomes **Win**. Click **Win** and the deal settles into `Won`,
its terminal state with no further button.

Every click is a real `POST .../jobs/:id/transition` call evaluated by
the FSM engine and appended to that job's event log — there's no mock
state in the app itself.

## What's next

The app you just built runs unchanged against any of FastYoke's three
run modes — only the base URL (and how you obtain a token) changes:

- **npx runtime → Docker full engine**: point `VITE_FASTYOKE_API_URL`
  at `http://127.0.0.1:8080` and use the Docker token/tenant id from
  Step 2's signup call. Do this once you need a feature the light
  runtime doesn't have — PDF rendering, the WASM scripting tier,
  extensions, e-signature, or marketplace apps.
- **Docker full engine → On-Prem**: the same container image deploys
  for production self-hosting behind your own firewall. See
  [On-Prem](/on-prem).
- **Either → Managed Cloud**: swap the base URL to your tenant's
  `api.fastyoke.com` endpoint and sign up for real. See
  [Getting started](/getting-started).

For the bigger picture on how the three modes compare, see the
[Runtime overview](/runtime).

Where to go from here:

- [App spec reference](/docs/cli/app-spec) — the full schema format for
  entities, FSM schemas, and forms, beyond what this walkthrough used.
- [SDK: build a CRUD UI](/docs/recipes/sdk-crud-ui) — a deeper recipe
  for entity list/detail/edit screens with the SDK.
- [Tutorials](/docs/tutorials) — end-to-end build flows, from a first
  API integration to authoring an extension.
- [Error envelope](/docs/developers/errors), [Idempotency](/docs/developers/idempotency),
  [Tenant scoping](/docs/developers/tenant-scoping), and
  [Rate limits](/docs/developers/rate-limits) — the day-1 platform
  invariants every API consumer needs, regardless of which run mode
  you're targeting.
