Varendo

Connect your data

Connect application-owned data and authority, then render a filtered table, semantic trend, reviewed form, and optional MCP tools.

Connect your own data and permissions to Varendo. You will render a filtered table, a trend chart, and a reviewed form in one region. The final section explains where to add an agent after the manual screen works.

New to Varendo? Run the small local quickstart first. Come back when you need real app data.

Open the local React quickstart →

Varendo 0.1.0 is the published stable release. Install all Varendo packages at the same exact version.

Start from the React TypeScript project created in the quickstart. You will add src/app.ts and src/PeopleTutorial.tsx, then replace the entry file once. All records in this guide are synthetic; the authority function is a demonstration principal that you must replace before using private data.

This tutorial uses the app-level API in @varendo/react/app. You mount a named region once, then send typed requests to it. The code below is the real, compiled tutorial source.

1. Install the packages

Shell example
npm install --save-exact \
  @varendo/core@0.1.0 \
  @varendo/runtime@0.1.0 \
  @varendo/web@0.1.0 \
  @varendo/react@0.1.0 \
  @varendo/agent@0.1.0 \
  react@19.2.8 react-dom@19.2.8 zod@4.5.4

2. Register resources and data

Create src/app.ts. It defines two resources. people has a stable id, labeled fields, and two allowed views. workforce-headcount stores one month-end snapshot per month; its measure is marked semi-additive so it is never summed across months.

The same file creates two bounded local data services and an authorize check that checks the synthetic principal provided by the tutorial authority. createAeliqoApp wires resources, data, and authority into one app instance. Swap the local service for your HTTP adapter when your server owns the records — keep credentials and grants out of requests.

src/app.ts · compiled source
import { defineResource } from '@varendo/core';
import { createLocalDataService, type AuthorizeRead, type DataRecord } from '@varendo/runtime/data';
import { createAeliqoApp } from '@varendo/web/app';
import { z } from 'zod';

export const people = defineResource({
  id: 'people',
  revision: 'people-1',
  label: 'People',
  identity: ['id'],
  schema: z.object({ id: z.string(), name: z.string(), team: z.string() }),
  fields: { name: { label: 'Name' }, team: { label: 'Team', role: 'dimension' } },
  presentation: { allowedViews: ['table', 'cards'] },
});

export const workforceHeadcount = defineResource({
  id: 'workforce-headcount',
  revision: 'headcount-1',
  label: 'Monthly workforce headcount',
  identity: ['id'],
  rowGrain: ['month'],
  schema: z.object({ id: z.string(), month: z.iso.date(), headcount: z.number().int() }),
  fields: {
    id: { label: 'Snapshot ID', hidden: true },
    month: { label: 'Month', role: 'time' },
    headcount: { label: 'Month end headcount', role: 'measure' },
  },
  measures: {
    'month-end-headcount': {
      label: 'Month end headcount',
      explanation: 'Employees active at the end of each month. Compare across months; never sum across time.',
      aggregate: 'sum',
      field: 'headcount',
      semiAdditiveOver: ['month'],
    },
  },
  presentation: { allowedViews: ['table', 'trend'], preferred: { analyze: 'trend' } },
});

export const HEADCOUNT = Object.freeze([
  { id: '2026-03', month: '2026-03-01', headcount: 118 },
  { id: '2026-04', month: '2026-04-01', headcount: 121 },
  { id: '2026-05', month: '2026-05-01', headcount: 124 },
  { id: '2026-06', month: '2026-06-01', headcount: 127 },
  { id: '2026-07', month: '2026-07-01', headcount: 126 },
  { id: '2026-08', month: '2026-08-01', headcount: 130 },
]);

function currentAuthority() {
  return {
    ok: true as const,
    value: {
      principalKey: 'current-user',
      scopeDigest: 'permitted-people',
      policyRevision: 'policy-1',
      experienceRevision: 'web-1',
      grants: ['task.evaluate', 'result.inspect', 'experience.commit'],
      readContext: { principal: 'current-user' },
    },
  };
}

export function createTutorialApp(records: readonly DataRecord[]) {
  const authorize: AuthorizeRead = ({ context }) =>
    context.principal === 'current-user'
      ? { ok: true as const, value: { scopeDigest: 'permitted-people', policyRevision: 'policy-1' } }
      : {
          ok: false as const,
          diagnostics: [{ code: 'people.denied', message: 'People access is denied.', retryable: false }],
        };
  const peopleData = createLocalDataService({
    snapshot: { catalog: people.catalog, sourceRevision: 'people-data-1', records: { people: [...records] } },
    sourceLimits: { rows: 1_000, bytes: 1_000_000 },
    authorize,
  });
  const headcountData = createLocalDataService({
    snapshot: {
      catalog: workforceHeadcount.catalog,
      sourceRevision: 'headcount-data-1',
      records: { 'workforce-headcount': HEADCOUNT },
    },
    sourceLimits: { rows: 1_000, bytes: 1_000_000 },
    authorize,
  });
  return createAeliqoApp({
    resources: [
      { resource: people, data: peopleData },
      { resource: workforceHeadcount, data: headcountData },
    ],
    authority: { read: currentAuthority },
  });
}

export function mountPeople(target: HTMLElement, records: readonly DataRecord[]) {
  const app = createTutorialApp(records);
  const mounted = app.mount({ target, regionId: 'people-main', resourceId: 'people' });
  if (!mounted.ok) throw new Error(mounted.diagnostics[0].message);

  return {
    app,
    render: () =>
      app.render({
        regionId: 'people-main',
        intent: {
          version: '1',
          id: 'browse-people',
          kind: 'browse',
          resource: 'people',
          fields: ['name', 'team'],
        },
      }),
    dispose: () => app.dispose(),
  };
}

You should see: createTutorialApp(records) returns one app instance with two registered resources.

3. Build the screen

Create src/PeopleTutorial.tsx using the full source below. AeliqoProvider shares the app; AeliqoRegion mounts people-main and renders the current intent. The React buttons select a request and the form owns its draft.

src/PeopleTutorial.tsx · compiled source
import { useState } from 'react';
import type { Intent } from '@varendo/core';
import { AeliqoProvider, AeliqoRegion } from '@varendo/react/app';
import { AeliqoForm, AeliqoSelect, AeliqoTextField } from '@varendo/react/inputs';
import type { createTutorialApp } from './app.js';

type TutorialApp = ReturnType<typeof createTutorialApp>;
type ViewKey = 'table' | 'engineering' | 'headcount';

const INTENTS: Readonly<Record<ViewKey, Intent>> = {
  table: {
    version: '1',
    id: 'browse-people',
    kind: 'browse',
    resource: 'people',
    fields: ['name', 'team'],
    preferredView: 'table',
  },
  engineering: {
    version: '1',
    id: 'browse-engineering',
    kind: 'browse',
    resource: 'people',
    fields: ['name', 'team'],
    filter: { op: 'compare', field: 'team', comparison: 'eq', value: 'Engineering' },
    preferredView: 'table',
  },
  headcount: {
    version: '1',
    id: 'monthly-headcount',
    kind: 'analyze',
    resource: 'workforce-headcount',
    measures: [{ id: 'month-end-headcount', revision: '1' }],
    time: { field: 'month', grain: 'month', calendar: 'gregorian', timezone: 'UTC' },
    preferredView: 'trend',
    sort: [{ field: 'month', direction: 'asc' }],
  },
};

export interface PeopleTutorialProps {
  readonly app: TutorialApp;
}

export function PeopleTutorial({ app }: PeopleTutorialProps) {
  const [view, setView] = useState<ViewKey>('table');
  const [name, setName] = useState('');
  const [team, setTeam] = useState('Engineering');
  const [formStatus, setFormStatus] = useState('No draft submitted.');
  const intent = INTENTS[view];

  return (
    <AeliqoProvider app={app}>
      <nav aria-label="People views">
        <button type="button" onClick={() => setView('table')}>
          All employees
        </button>
        <button type="button" onClick={() => setView('engineering')}>
          Engineering
        </button>
        <button type="button" onClick={() => setView('headcount')}>
          Monthly headcount
        </button>
      </nav>

      <AeliqoRegion
        key={intent.resource}
        regionId="people-main"
        resourceId={intent.resource}
        intent={intent}
        onReceipt={(receipt) => {
          if (receipt.status !== 'renderer-ready') console.error(receipt.diagnostics);
        }}
      />

      <AeliqoForm
        label="Add employee draft"
        onSubmit={(event) => {
          event.preventDefault();
          setFormStatus(`Review ${name || 'unnamed employee'} for ${team} before executing an action.`);
        }}
      >
        <AeliqoTextField
          label="Name"
          name="name"
          value={name}
          required
          onValueChange={(event) => setName(event.detail.value)}
        />
        <AeliqoSelect
          label="Team"
          name="team"
          value={team}
          options={[
            { value: 'Design', label: 'Design' },
            { value: 'Engineering', label: 'Engineering' },
          ]}
          onValueChange={(event) => setTeam(event.detail.value)}
        />
        <button type="submit">Review employee</button>
      </AeliqoForm>
      <p role="status">{formStatus}</p>
    </AeliqoProvider>
  );
}

4. Mount and run it

Replace src/main.tsx with this entry. Hot module replacement disposes this example; a real route should perform the same cleanup in its teardown hook:

React TypeScript example
import { createRoot } from 'react-dom/client';
import { createTutorialApp } from './app.js';
import { PeopleTutorial } from './PeopleTutorial.js';

const app = createTutorialApp([
  { id: 'ada', name: 'Ada Chen', team: 'Design' },
  { id: 'sam', name: 'Sam Rivera', team: 'Engineering' },
]);

const target = document.getElementById('root');
if (!target) throw new Error('Missing root container.');
const root = createRoot(target);
root.render(<PeopleTutorial app={app} />);
if (import.meta.hot) import.meta.hot.dispose(() => {
  root.unmount();
  app.dispose();
});

Run npm run dev and open the printed URL.

You should see: three buttons drive the region — a table of people, a filtered table, and a headcount trend — plus a review-only form.

5. Try each request

6. Expose the region to an agent

Create src/agent.ts. createAppToolEndpoint pairs one mounted region with a transport and gives the agent three tools: aeliqo_context, aeliqo_render, aeliqo_act. The host picks the region, transport, expiry, and size limits. The agent cannot grant itself permissions or supply markup.

src/agent.ts · compiled source
import { createAppToolEndpoint } from '@varendo/agent/app';
import type { createTutorialApp } from './app.js';

type TutorialApp = ReturnType<typeof createTutorialApp>;

export function createPeopleAgentSession(app: TutorialApp) {
  const endpoint = createAppToolEndpoint({
    runtime: app.runtime,
    render: app,
    regionId: 'people-main',
    goalEpoch: crypto.randomUUID(),
    transport: 'mcp',
    expiresAt: Date.now() + 15 * 60_000,
    maxPending: 2,
    maxMilliseconds: 15_000,
    maxInputBytes: 32_000,
    maxOutputBytes: 64_000,
  });
  if (!endpoint.ok) throw new Error(endpoint.diagnostics[0].message);
  return endpoint.value;
}

Attach the endpoint to the local MCP transport and close it on disconnect or expiry. Keep every button and form working when no agent is connected.

Check lifecycle behavior

  • All employees renders a table on wide containers and an allowed compact view on narrow ones.
  • Engineering shows only the Engineering rows you passed in, with the filter visible.
  • Monthly headcount plots 118 through 130 by month and never sums them.
  • The form keeps its draft and writes nothing before host review.
  • The MCP endpoint expires, disconnects, and rechecks permissions on every tool call.
  • Unmounting the screen disposes the region; disposing the app cancels remaining work.

Recover from an unsuccessful request

StatusNext action
needs-inputShow the returned choice or form field and send a more specific request.
unsupportedCheck the resource's requests, registered views, and presentation policy.
deniedFix host authorization. Never put a grant inside the request.
failedShow the diagnostic and keep the previous valid view.
cancelledTreat unmount and superseded requests as normal. Retry only on a new user action.