ArArSite

Developer Documentation

Build applications for ArSite — an intelligence layer for Canada, built in Canada.

Quick start

ArSite apps are hosted: you run your own backend and UI on any infrastructure (Railway, Vercel, Fly, your own metal). ArSite renders your UI in a sandboxed iframe and proxies every API call through its gateway — auth, entitlement, per-cycle metering, and HMAC signing happen on the platform side. You write the iframe, handle the API, and verify HMAC on your backend.

1. Scaffold a new app

npx create-arsite-app \
  --id=my-app \
  --name="My App" \
  --framework=next-iframe \
  --dir=./my-app
cd my-app
npm install
npm run dev   # iframe app serves at http://localhost:3000/embed

Two templates ship with the scaffolder: next-iframe (Next.js app with iframe entry + API route) and express-api (API only, BYO frontend).

2. Run the local gateway proxy

arsite dev runs a local gateway on :7400 that stamps fake auth + HMAC headers and forwards to your backend. Lets you exercise the full proxy path without deploying.

# in another terminal
npx @arsite/cli dev       # local gateway on :7400
npx @arsite/cli validate  # schema + frame ping + healthcheck

3. Deploy + submit

  • Deploy your frontend (the iframe app) and backend to any HTTPS hosts.
  • Apply for a developer account at /developer (1–2 business days).
  • Create a new app listing — wizard collects basics, integration URLs, media, pricing, legal, then upload your arsite.config.tsmanifest in the “Frame & API” tab and store your signing secret in the “Secrets” tab.
  • Submit for review. Automated checks run first; manual review follows.

Publish an app

Publishing is five steps. You always create a listing in the portal; the SDK and CLI are how you author and validate the manifest and code the listing points at.

  1. 1

    Apply for a developer account at /developer

    Approval typically takes 1–2 business days.
  2. 2

    Create a listing in the new-app wizard

    Set metadata, the integration type (hosted with UI, hosted API-only, or listed), media, pricing, and legal links.
  3. 3

    Upload your manifest and secrets

    In the listing's Frame & API tab, upload arsite.config.ts; store your signing secret in the Secrets tab.
  4. 4

    Submit for review

    Automated checks (schema + reachability) run synchronously; manual review follows. See Submission & review for the status machine.
  5. 5

    Publish

    Approved listings flip to published, the manifest is written to app_registry, and the app appears on ArSite Apps.

Choose your authoring path

 UI wizard (/developer/apps/new)SDK + CLI (create-arsite-app)
Best forListing-first; simple / listed / API-only apps; non-engineers on the team.Engineers shipping a hosted iframe app with real routes.
ProducesA draft listing + initial pricing tiers.A scaffolded project + arsite.config.ts you upload to the listing.
ManifestGenerated minimally on submit (basics only).Hand-authored, version-controlled, validated by arsite validate.
WhenAlways required (the listing lives in the portal).Recommended whenever you have a backend/frontend to host.
You always create a listing in the portal. The SDK/CLI is how you author and validate the manifest and code that the listing points at. They are complementary, not alternatives.
Back to top

How apps work

Every request from a tenant's iframe takes the same path:

browser (tenant)
  → arsite.ca/{tenantSlug}/apps/{appId}
  → <iframe src="https://yourmod.example.com/embed" sandbox=...>
       loads @arsite/sdk/iframe
       handshake: postMessage(app:ready) ↔ postMessage(arsite:init { JWT, user, tenant, ... })
       arsite.api('/v1/search?q=foo') →
  → arsite.ca/api/apps/{appId}/v1/search?q=foo  (Bearer JWT)
       1. validate JWT (5-min HS256)
       2. resolve manifest from app_registry
       3. match route → look up billingUnits
       4. check subscription + per-cycle quota
       5. decrypt upstream + signing secrets
       6. HMAC-sign canonical string
       7. forward to your backend with x-arsite-{signature,timestamp,user,tenant,app}
       8. record usage_event { units, latency, status }
  → yourbackend.example.com/v1/search?q=foo
       verifyArsiteRequest() → user/tenant/appId are trustable headers
Trust boundary:the iframe is untrusted (it's your HTML); your backend is untrusted-but-authenticated (it must HMAC-verify before trusting x-arsite-user / x-arsite-tenant).

Integration variants

Every app declares an integration in its manifest. Pick the variant that matches what you are shipping — it decides what renders in the ArSite shell.

VariantWhen to useRenders in the shell
hosted (frame)You host a UI and a backend. The default.Your iframe at the frame route, plus any docs/api/usage tabs.
hosted (API-only)A metered REST API with no UI of its own. Set hosted.ui: 'api-only' and omit the frame.No iframe — Docs and API access tabs only. Must declare at least one non-frame route.
listedYour product lives on your own site; ArSite lists it for discovery. Optionally monetize an API via billing.mode: 'rest-proxy'.An external “Open” link + optional Docs/API/Usage tabs. No iframe.
nativeReserved for first-party ArgonBI apps built inside the monorepo.Native pages.

Tabs

An app declares one or more routes; the shell renders each as a tab. Each route has a kind that selects how it renders:

  • frame — mounts your iframe at path (hosted-frame apps only; the only kind that mints an iframe JWT).
  • docs — platform-rendered docs (from docsUrl or the listing description). Available to every variant.
  • api — the route table, gateway base, and public REST passthrough, for self-serve API consumers.
  • usage— the caller's usage for this app.
API-only and listed apps have no frame, so they must declare at least one non-frame tab — otherwise there is nothing to render.
Back to top

SDK · types & manifest

@arsite/sdk/types exports the Zod schema for the hosted-app manifest and a typed factory.

// arsite.config.ts
import { defineHostedApp } from '@arsite/sdk/types';

export default defineHostedApp({
  id: 'my-app',                  // kebab-case, 3–48 chars
  name: 'My App',
  version: '0.1.0',                 // semver
  description: '…',
  icon: 'database',                 // Lucide icon name
  category: 'intelligence',         // analytics | monitoring | reporting | intelligence
  permissions: ['my-app:read', 'my-app:api'],
  routes: [{ path: '/', label: 'Home', icon: 'home' }],
  dependencies: [],
  integration: 'hosted',
  hosted: {
    frame: {
      url: 'https://my-app.example.com/embed',
      initialHeight: 700,
    },
    upstream: {
      baseUrl: 'https://my-app-api.example.com',
      auth: { type: 'none' },        // bearer | header | none
      // timeoutMs: 30000,
    },
    signing: { secretRef: 'my-app.signing_secret' },
    healthcheck: { path: '/health' },
    routes: [
      { method: 'GET',  pattern: '/v1/search',         billingUnits: 1   },
      { method: 'GET',  pattern: '/v1/items/:id',      billingUnits: 0.5 },
      { method: 'POST', pattern: '/v1/predict',        billingUnits: 5   },
      { method: 'GET',  pattern: '/health',            billingUnits: 0   },
    ],
  },
});

Per-route fields

FieldRequiredNotes
methodyesGET / POST / PUT / PATCH / DELETE
patternyesStarts with /. Supports :param segments. First match wins — list more specific routes first.
billingUnitsyesPer-call cost. 0 = free. Fractional values are fine (e.g. 0.5). Your backend can override per-response with X-Arsite-Units.
tierGatenoList of pricing-tier keys allowed to call this route.
cacheno{ ttlSeconds, varyByUser?, bill? }

SDK · iframe runtime

@arsite/sdk/iframe is what your UI imports. createArsiteClient() handshakes with the parent shell and returns a typed client.

'use client';
import { useEffect, useState } from 'react';
import { createArsiteClient, type ArsiteClient } from '@arsite/sdk/iframe';

export default function Embed() {
  const [client, setClient] = useState<ArsiteClient | null>(null);

  useEffect(() => {
    createArsiteClient().then(setClient);
  }, []);

  useEffect(() => { if (client) client.resize.auto(); }, [client]);

  if (!client) return <p>Connecting…</p>;

  return (
    <div>
      <h1>Hi {client.user.name}</h1>
      <button onClick={async () => {
        const r = await client.api('/v1/search?q=foo');
        const json = await r.json();
        console.log(json);
      }}>Search</button>
    </div>
  );
}

Client surface

MemberPurpose
client.user{ id, email, name, role, locale }
client.tenant{ id, slug, name, planTier } — empty when the caller is acting individually (not under an organization).
client.app{ id, version, subscriptionTier, quotaRemaining }
client.api(path, init?)Signed-fetch wrapper. Adds Authorization: Bearer JWT and prepends the gateway base. Returns a standard Response.
client.themeDesign tokens (cream/charcoal/forest/...) for visual consistency.
client.toast.{info,success,error}Surface a toast in the ArSite shell.
client.resize.auto()Auto-resize the iframe based on body height. Returns a disposer for cleanup.
client.events.on(evt, cb)Subscribe to tenantChange, themeChange, quotaWarning, visibility.

React adapters

import {
  ArsiteProvider,
  useArsite,
  useArsiteUser,
  useArsiteTenant,
  useArsiteApp,
  useArsiteApi,
  useArsiteTheme,
} from '@arsite/sdk/iframe/react';

export function App() {
  return (
    <ArsiteProvider>
      <Probe />
    </ArsiteProvider>
  );
}

function Probe() {
  const user = useArsiteUser();
  const api = useArsiteApi();
  return <p>Hi {user?.email}</p>;
}

SDK · backend verifier

@arsite/sdk/server exposes one function: verifyArsiteRequest(). Call it at the top of every protected handler. It throws unless the HMAC signature + timestamp are valid; on success it returns the verified user/tenant/app ids.

// app/api/v1/search/route.ts  (Next.js)
import { verifyArsiteRequest } from '@arsite/sdk/server';

export async function GET(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const body = await req.text();
  const verified = await verifyArsiteRequest(
    {
      method: 'GET',
      path: url.pathname + url.search,
      body,
      headers: Object.fromEntries(req.headers),
    },
    { signingSecret: process.env.ARSITE_SIGNING_SECRET! },
  );
  // verified.user.id, verified.tenant.id, verified.appId are now trustable.
  return Response.json({ message: `hello ${verified.user.id}` });
}

Canonical-string contract (for non-TS backends)

The protocol is small and language-agnostic. Reproduce in any backend with ~20 lines.

canonical = `${method}\n${path}\n${timestamp}\n${sha256_hex(body)}\n${appId}\n${userId}\n${tenantId}`
signature = "sha256=" + hmac_sha256_hex(signing_secret, canonical)

# Headers sent by the gateway:
#   x-arsite-signature: sha256={hex}
#   x-arsite-timestamp: {unix-seconds}
#   x-arsite-user: {clerkUserId}
#   x-arsite-tenant: {tenantUuid OR 00000000-0000-0000-0000-000000000000 if user-owned}
#   x-arsite-app: {appId}
#   x-arsite-protocol-version: 1

See the DataFun reference app's service/datafun_api/arsite_auth.py for a complete Python implementation (FastAPI / Starlette).

Native design

Iframe apps can match the ArSite shell without reverse-engineering the design tokens. @arsite/sdk/theme ships the full palette, a Tailwind preset, and a small set of tokenized primitives.

Tokens + live theme

import { arsiteTokens, applyTheme } from '@arsite/sdk/theme';

// Write the shell's live theme to :root as CSS variables (--arsite-forest, …).
// Tracks future dark mode automatically instead of hardcoding hex.
client.theme.apply();              // convenience on the iframe client
// or, manually:
applyTheme(client.theme);

Tailwind preset

// tailwind.config.ts
import arsitePreset from '@arsite/sdk/theme/tailwind';

export default {
  presets: [arsitePreset],   // cream, linen, forest, gold, rounded-xl, Inter, no default blue
  content: ['./src/**/*.{ts,tsx}'],
};

Primitives

@arsite/sdk/theme/react exports thin, tokenized Button, Card, Input, Callout, Table, and Badge (peer-dep React). If you already have shadcn components, bring your own and just apply the preset.

Design rules: no pure black/white, generous rounding (rounded-xl/rounded-2xl), Inter everywhere, Lucide-only icons.
Back to top

Cross-app composition

An app can build on another publishedArSite app's public API. With the user's consent, your backend calls the upstream app's metered routes through the platform — usage and revenue are attributed correctly to both sides.

Declaring a dependency

// arsite.config.ts (caller app)
appDependencies: [
  {
    appId: 'upstream-app',         // the published app you depend on
    routes: ['/v1/data'],          // the routes you intend to call
    required: true,
    reason: 'Fetch base data to enrich our analysis.',  // shown to the user at consent
  },
],

Exposing routes to other apps

An upstream app only shares a route if it opts in. Set app-level exposesPublicApi: true and mark each shareable route composable: true. Only composable routes appear in the service registry and are callable via delegation.

How the call flows

  • Your backend mints a delegated token at POST /api/apps/{callerAppId}/delegate/{upstreamAppId}/token for the current subject, then calls /api/apps/{upstreamAppId}/{...path} with it.
  • The gateway requires the matched route to be composable and an active grant covering it; otherwise 403.
  • Billing: usage is charged to the subject's subscription to the upstream app (so its developer earns). The subject must be subscribed to both apps.
Apps that declare appDependencies always get full (not expedited) manual review — the reviewer confirms the upstream app exposes the requested routes as composable and that the stated reason is accurate.
Back to top

@arsite/cli

CommandPurpose
npx @arsite/cli devLocal gateway proxy on :7400. Reads your arsite.config.ts, stamps fake user/tenant + HMAC headers, forwards requests to your local backend. Set ARSITE_SIGNING_SECRET to a value your backend also has.
npx @arsite/cli validateValidates the manifest against the Zod schema and pings frame URL + upstream healthcheck. Use --ping=false to skip network checks.
npx @arsite/cli logsTails usage events for an app (SSE). Requires ARSITE_DEV_KEY + --id=<appId>.

Gateway protocol

Request lifecycle

  • JWT validation — HS256, issued by the platform, 5-minute TTL, audience app-iframe, signed with ARSITE_JWT_SIGNING_KEY.
  • App match — manifest is loaded from the app_registry table by id.
  • Route match — first-match-wins against hosted.routes[].
  • Subscription + quota— looks up the caller's subscription; UPSERTs into app_subscription_usage; refunds + returns 429 if over the included monthly units.
  • HMAC sign— canonical string SHA-256 HMAC with the app's signing secret.
  • Forward — adds dev-declared upstream auth (Bearer or custom header) + the x-arsite-* headers, hits your backend.
  • Usage — writes a row to usage_events with the billed units (overridable by X-Arsite-Units response header for variable-cost ops like LLM tokens).

Response codes

StatusMeaning
200–2xxPass-through from your backend.
401Missing or invalid JWT.
403 not subscribedCaller is not subscribed to this app.
404 route not declaredPath/method not in the manifest's hosted.routes.
429 quota exceededMonthly units exhausted. Retry-After + X-Arsite-Quota-Reset headers indicate cycle end.
503 feature disabledPlatform-wide kill switch (MARKETPLACE_HOSTED_MODULES_ENABLED=false).
504 upstream timeoutYour backend didn't respond within hosted.upstream.timeoutMs (default 30s).

Submission & review

Stage 1 — Automated checks (synchronous)

  • Listing fields (name, description, screenshots, category, pricing) complete.
  • Manifest passes the Zod schema in @arsite/sdk/types.
  • Frame URL responds (HEAD within 10s).
  • Upstream healthcheck (baseUrl + healthcheck.path) returns the expected status (default 200).

On failure the submission flips to auto_check_failedand won't enter the manual queue. Fix and resubmit.

Stage 2 — Manual ArgonBI review

  • Typical turnaround: 2–5 business days.
  • Approve / Reject / Request changes. Apps in changes_requested can be resubmitted directly.
  • Approved listings flip to published and appear on ArSite Apps. The manifest is written to app_registry atomically.

Updating an app

  • Bump version in arsite.config.tsand re-submit through the “Frame & API” tab.
  • Patch/minor bumps: expedited review.
  • Major bumps (route changes, permission changes, pricing changes): full review again.

Sandbox

Two ways to run an app without installing it — they serve different audiences:

  • Dev preview — from your app's edit page, click Preview. Renders your app in the platform shell for 15 minutes using your own user as the test subject. Owner-only; no cooldown; no install.
  • End-user trial — any signed-in user on /apps/{appId} can hit Try for 15 minutes. Capped at 50 calls and one trial per (user, app) per day. Disable it per-app from the Preview card on your edit page (e.g. enterprise-only or cost-heavy apps).
 Dev previewEnd-user trial
Route/api/sandbox/dev/{appId}/token/api/sandbox/trial/{appId}/token
WhoApp owner only.Any signed-in user from /apps/{appId}.
Duration15 min15 min
Call capdev cap50 calls
Cooldownnoneone trial per (user, app) per day
Billingunits = 0 (sandbox_call)units = 0 (sandbox_call)
Disablealways available to ownerper-app via Disable trial sandbox

What your backend sees

Sandbox traffic goes to the same upstream URL as production. The gateway adds two headers so you can branch on them:

X-Arsite-Sandbox: true
X-Arsite-Sandbox-Mode: dev | trial

Read-only apps (search, lookups) work in sandbox with zero changes. Apps that mutate state should detect the header and either no-op, return mock data, or write to a sandbox-only table.

Pricing & metering

Set tiers in the “Pricing” tab on your app's edit page. Each tier declares a fixed included_monthly_units quota. The gateway atomically increments per-cycle units on every billable call and refunds + returns 429 if a request would push usage over the cap.

Variable-cost routes

For operations whose cost is data-dependent (LLM tokens, report rows), return X-Arsite-Units: 42on the response and the gateway will bill that value instead of the manifest's declared billingUnits.

// Express example
app.post('/v1/predict', async (req, res) => {
  const verified = await verifyArsiteRequest(...);
  const result = await runLLM(req.body);
  res.set('X-Arsite-Units', String(result.tokensUsed));
  res.json({ result: result.text });
});

Public REST API

Full interactive Swagger UI with all endpoints, request/response schemas, and try-it-out: /developer/api-reference. OpenAPI 3.1 spec at https://arsite.ca/api/v1/openapi.json.

The public REST API is what externalcallers (your own scripts, a CI job, a customer's server) use to query published apps — separate from the iframe runtime. Auth is via a per-tenant API key.

Base URL

https://arsite.ca/api/v1/

Authentication

Authorization: Bearer arsite_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Generate keys in Dashboard → API Keys. Keys are user-owned (optionally tenant-scoped) and can be scoped to specific apps.

Endpoints

MethodPathAuthPurpose
GET/healthNonePlatform health.
GET/appsBearerList published apps.
GET/apps/{appId}BearerApp metadata + pricing tiers.
GET/usageBearerThis tenant's API-call usage for the current cycle.
GET/POST/apps/{appId}/{...path}BearerPassthrough to the app's upstream API (only for apps you have an active subscription to and the key is scoped to).

Rate limits

Per-key sliding-window limit (default 1,000 req/hr). Exceeding returns 429 with Retry-After. Tier-specific limits are configurable on the key.

OAuth (developer API)

Developer accounts get OAuth client credentials so backend tooling can mint access tokens without storing a long-lived API key. Find your Client ID, Client Secret, and Refresh Token in Developer → Settings.

client_credentials grant

curl -X POST https://arsite.ca/api/oauth/token \
  -H 'content-type: application/json' \
  -d '{
    "grant_type": "client_credentials",
    "client_id":     "arsite_cid_…",
    "client_secret": "arsite_cs_…"
  }'
# → { "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }

refresh_token grant

curl -X POST https://arsite.ca/api/oauth/token \
  -H 'content-type: application/json' \
  -d '{
    "grant_type":   "refresh_token",
    "client_id":    "arsite_cid_…",
    "refresh_token":"eyJ…"
  }'
# → { "access_token": "...", "refresh_token": "...new...", "token_type": "Bearer", "expires_in": 3600 }

Access tokens are 1-hour JWTs with scopes api:invoke, read:modules. Refresh tokens rotate on every use.

Revenue & payouts

ArSite's revenue share is configurable per tier. Standard developers keep —%; trusted developers keep —%. Admins may set a custom per-developer rate.

Stripe Connect

  • Start onboarding at Developer → Revenue. Stripe requires identity verification and a bank account.
  • Free apps don't need Stripe Connect.
  • Payouts on the 1st of each month, $50 CAD minimum, 2–5 business day transfer.