> ## Documentation Index
> Fetch the complete documentation index at: https://developers.zite.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a CRM

> A complete walk-through: a sales-pipeline CRM with a database, backend workflows, a pipeline board, a KPI row, row-level access so reps see their own deals, and publishing.

You build it by describing what you want to an agent through the [Zite MCP](/mcp/overview); the code below is what it generates.

## Describe the app

```text theme={null}
Create a workspace called "CRM", then build a sales-pipeline app.
Contacts have a name, email, company, and phone. Deals have a title, a
value, a stage (Lead / Qualified / Proposal / Won / Lost), a linked contact,
a close date, and an owner. I want a pipeline board I can drag deals across,
a KPI row showing open and won value, and reps should only see their own deals.
```

## Data model

`Contacts` and `Deals` (linked to contacts), plus an optional `Activities` log. Field/table names become **SDK names** in code (`Deals` → `zite.Deals`, "Close date" → `closeDate`); `ownerEmail` is what row-level permissions key off. See [Databases](/concepts/databases).

| Table          | Field (SDK name) | Type                                                      |
| -------------- | ---------------- | --------------------------------------------------------- |
| **Contacts**   | `name`           | single\_line\_text (primary)                              |
|                | `email`          | email                                                     |
|                | `company`        | single\_line\_text                                        |
|                | `phone`          | phone\_number                                             |
| **Deals**      | `title`          | single\_line\_text (primary)                              |
|                | `value`          | currency                                                  |
|                | `stage`          | single\_select — Lead / Qualified / Proposal / Won / Lost |
|                | `contact`        | linked\_record → Contacts                                 |
|                | `closeDate`      | date                                                      |
|                | `ownerEmail`     | email                                                     |
| **Activities** | `summary`        | single\_line\_text (primary)                              |
|                | `type`           | single\_select — Call / Email / Meeting / Note            |
|                | `deal`           | linked\_record → Deals                                    |

## Workflows

One file per workflow in `src/api/` — list deals, and move one through the pipeline:

```typescript theme={null}
// apps/crm/src/api/listDeals.ts
import { z } from 'zod';
import { createEndpoint } from 'zitejs/backend';
import { zite } from 'zitejs/db';

export default createEndpoint({
  description: 'List deals, optionally filtered by stage',
  authenticated: true,
  inputSchema: z.object({ stage: z.string().optional() }),
  outputSchema: z.object({
    deals: z.array(z.object({
      id: z.string(),
      title: z.string(),
      value: z.number().nullable(),
      stage: z.string(),
    })),
  }),
  execute: async ({ input }) => {
    const { records } = await zite.Deals.findAll({
      filter: input.stage ? { stage: input.stage } : undefined,
      sort: [{ field: 'closeDate', direction: 'asc' }],
      limit: 500,
    });
    return {
      deals: records.map(d => ({ id: d.id, title: d.title, value: d.value, stage: d.stage })),
    };
  },
});
```

```typescript theme={null}
// apps/crm/src/api/moveDeal.ts
import { z } from 'zod';
import { createEndpoint, ZiteError } from 'zitejs/backend';
import { zite } from 'zitejs/db';

export default createEndpoint({
  description: 'Move a deal to a different pipeline stage',
  authenticated: true,
  inputSchema: z.object({
    dealId: z.string(),
    stage: z.enum(['Lead', 'Qualified', 'Proposal', 'Won', 'Lost']),
  }),
  outputSchema: z.object({ ok: z.boolean() }),
  execute: async ({ input }) => {
    const deal = await zite.Deals.findOne({ id: input.dealId });
    if (!deal) throw new ZiteError('Deal not found', { statusCode: 404 });
    await zite.Deals.update({ id: input.dealId, record: { stage: input.stage } });
    return { ok: true };
  },
});
```

The KPI row sums deal value per stage — a group-by, so use `zite.sql()`; aggregating over a capped `findAll` undercounts ([database client](/framework/database-client)):

```typescript theme={null}
// apps/crm/src/api/pipelineSummary.ts
import { z } from 'zod';
import { createEndpoint } from 'zitejs/backend';
import { zite } from 'zitejs/db';

export default createEndpoint({
  description: 'Total deal value and count grouped by pipeline stage',
  authenticated: true,
  inputSchema: z.object({}),
  outputSchema: z.object({
    byStage: z.array(z.object({ stage: z.string(), total: z.number(), count: z.number() })),
    openValue: z.number(),
    wonValue: z.number(),
  }),
  execute: async () => {
    const { rows } = await zite.sql({
      query: `
        SELECT "stage", COALESCE(SUM("value"), 0) AS "total", COUNT(*) AS "count"
        FROM "Deals"
        GROUP BY "stage"
      `,
    });
    const byStage = rows.map(r => ({
      stage: String(r.stage), total: Number(r.total), count: Number(r.count),
    }));
    const sumOf = (stages: string[]) =>
      byStage.filter(s => stages.includes(s.stage)).reduce((n, s) => n + s.total, 0);
    return {
      byStage,
      openValue: sumOf(['Lead', 'Qualified', 'Proposal']),
      wonValue: sumOf(['Won']),
    };
  },
});
```

Add workflows, then run `npx zitejs generate` so the typed frontend client picks them up. See [Workflows](/framework/workflows).

## Frontend

A KPI row over a drag-and-drop pipeline board, calling workflows through the generated `zitejs/api` client:

```tsx theme={null}
// apps/crm/src/App.tsx
import { useEffect, useState } from 'react';
import { pipelineSummary, PipelineSummaryOutputType } from 'zitejs/api';
import { Card, CardContent } from '@project/components/ui/card';
import { PipelineBoard } from './components/PipelineBoard';

const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });

export default function App() {
  const [summary, setSummary] = useState<PipelineSummaryOutputType>();
  useEffect(() => { pipelineSummary({}).then(setSummary); }, []);

  return (
    <div className="p-6 space-y-6">
      <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
        <Kpi label="Open pipeline" value={summary ? money(summary.openValue) : '—'} />
        <Kpi label="Won" value={summary ? money(summary.wonValue) : '—'} />
        {summary?.byStage.map(s => (
          <Kpi key={s.stage} label={s.stage} value={`${s.count} · ${money(s.total)}`} />
        ))}
      </div>
      <PipelineBoard />
    </div>
  );
}

function Kpi({ label, value }: { label: string; value: string }) {
  return (
    <Card>
      <CardContent className="p-4">
        <div className="text-sm text-muted-foreground">{label}</div>
        <div className="text-2xl font-semibold">{value}</div>
      </CardContent>
    </Card>
  );
}
```

`PipelineBoard` groups `listDeals` by `stage` into columns and calls `moveDeal` on drop (persist optimistically). Full drag pattern: [Kanban recipe](/recipes/kanban-board).

## Permissions

Reps see only deals they own; managers see everything. Because each deal carries `ownerEmail`, a `rowFilter` comparing it to the signed-in user does the job:

```json theme={null}
{
  "version": "1.0",
  "defaultPolicy": "deny",
  "roles": [
    { "id": "builtin:all-team-members", "name": "All team members" },
    { "id": "role_manager", "name": "Sales manager" }
  ],
  "tables": {
    "Deals": {
      "rules": [
        {
          "roles": ["All team members"],
          "operations": ["read", "create", "update"],
          "rowFilter": { "type": "comparison", "field": "ownerEmail", "operator": "eq", "userField": "email" }
        },
        { "roles": ["Sales manager"], "operations": ["read", "create", "update", "delete"] }
      ]
    },
    "Contacts": {
      "rules": [
        { "roles": ["All team members"], "operations": ["read", "create", "update"] }
      ]
    }
  }
}
```

The `rowFilter` scopes each rep to their own deals via `userField: "email"`; the **Sales manager** role gets unfiltered access. `defaultPolicy: "deny"` leaves `Activities` locked until you add rules. See the [permissions model](/concepts/permissions) and [file schema](/framework/permissions-file).

## Publish

Ask the agent to `Publish the app`. Internal apps go live to your team; external apps get a `zite.so` URL with sign-in. See [Publishing](/deploy/publishing).
