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#
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.
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.
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:
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#
- All employees shows Ada and Sam. Narrow the container to see an allowed compact view.
- Engineering keeps Sam. The typed filter names a declared field; the view reflects the evaluated result rather than manually hidden DOM rows.
- Monthly headcount plots six month-end values, from 118 to 130. The measure is semi-additive and declares a monthly Gregorian time axis in UTC. It must not be summed across months.
- Enter a name and choose Review employee. The status asks for review; no employee is saved. A real write goes through a registered action and fresh server authorization.
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.
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#
| Status | Next action |
|---|---|
needs-input | Show the returned choice or form field and send a more specific request. |
unsupported | Check the resource's requests, registered views, and presentation policy. |
denied | Fix host authorization. Never put a grant inside the request. |
failed | Show the diagnostic and keep the previous valid view. |
cancelled | Treat unmount and superseded requests as normal. Retry only on a new user action. |
