@shubh90/app-runtime
v0.10.5
Published
The platform contract every mii org app depends on — sign-in, chat with the org's agents, and the config an app must not diverge from. A versioned package, not files copied into each repo.
Readme
@shubh90/app-runtime
The platform contract every org app depends on — sign-in and the Next.js config an app must not diverge from — as a versioned package instead of files copied into each app repo on every agent run.
This is Phase 0 + Phase 1 of docs/handoffs/2026-08-24-platform-runtime-plan.md:
replace the kit-sync "vendoring" model (force-overwrite platform files in each
repo, which caused the fleet-wide TS2307, the stuck rebases, and the framing
white-screens) with a dependency an app installs and pins.
What's in it
@shubh90/app-runtime/auth—createMiiAuth(), the whole sign-in flow (the platform client, session, the login/logout route handlers,SIGN_IN_PROBLEMS,LOGIN_ACTION,safeNext). Self-contained: it depends only onpostgresandnext, never on the app's own code. The app still renders its own login page look.@shubh90/app-runtime/next—withMiiPlatform(nextConfig), which force-merges the framing headers the platform's App pane needs so an app cannot forbid its own embedding (the Newmark white-screen bug becomes impossible).
Why a package (not the kit sync)
A published package physically cannot import "@/components/...", so the
class of bug where a synced login page imported an app UI component it lacked is
gone. Nothing is written into the app's tree each run, so the tree stays clean
and autosave never fights a kit-dirtied tree. Apps pin a version; a breaking
platform contract change is a semver major with a deprecation window, not a flag
day.
Publishing (npmjs, public)
Every released version (0.1.0 →) lives on the PUBLIC npm registry — that is
where app installs resolve, with no auth to provision in any build
environment, which is the point. Publish from a machine logged in as
shubh90:
# from packages/app-runtime:
npm run check
npm version patch # or minor / major — updates the lockfile with it
npm publish --registry=https://registry.npmjs.orgprepack builds and checks what is about to be sent, so the tarball is this
build of src whether or not anyone remembered to build first. Publishing is by
hand and on purpose: there is no CI release for this package.
Then bump whoever should take it. ^0.5.0 does not accept 0.6.0 — npm reads a
caret on a 0.x version as the minor being fixed — so a release reaches apps only
when their own dependency moves.
Installing in an org app
Nothing to configure. The package is public, which is the point: an app's build environment needs no token to install the platform's half of itself.
pnpm add @shubh90/app-runtimeThe chat and invoke server handlers import zod/v4, so the app needs zod >= 3.25.
Wiring it up (what each app file becomes)
One binding (src/lib/mii-auth.ts) — this is where the old tables.ts
config goes now:
import { createMiiAuth } from "@shubh90/app-runtime/auth";
import * as Sentry from "@sentry/nextjs"; // optional
export const auth = createMiiAuth({
// Only if the app kept a pre-kit user table (CBRE, Newmark, WBP):
// users: { table: "user", idFrom: "kit", emailRequired: true, adoptByEmail: true, ensureSchema: false, insertDefaults: { emailVerified: false } },
reportError: (error, tags) => Sentry.captureException(error, { tags })
});The route handlers become one line each:
// src/app/api/mii-auth/login/route.ts
import { auth } from "@/lib/mii-auth";
export const dynamic = "force-dynamic";
export const POST = (request: Request) => auth.loginRoute(request);
// src/app/api/mii-auth/logout/route.ts
import { auth } from "@/lib/mii-auth";
export const dynamic = "force-dynamic";
export const POST = () => auth.logoutRoute();Guarding pages (getUser / requireUser as before):
import { auth } from "@/lib/mii-auth";
const user = await auth.requireUser(); // in the authenticated layout
const user = await auth.getUser(); // in API routes: MiiUser | nullThe login page stays the app's own look. It reads the pieces from the
binding rather than a copied problems.ts / route:
import { auth } from "@/lib/mii-auth";
const problem = params.error ? auth.SIGN_IN_PROBLEMS[params.error] : null;
// <form method="post" action={auth.LOGIN_ACTION}> ... </form>next.config wraps the app's own config:
import { withMiiPlatform } from "@shubh90/app-runtime/next";
export default withMiiPlatform({ /* the app's own next config */ });Contracts: a button that asks the org's agents
Needs zod >= 3.25 in the app (the handler imports zod/v4). The app declares
each contract once; the id is what mii contract create printed.
// src/contracts.ts
import { defineContracts } from "@shubh90/app-runtime/invoke/server";
import { z } from "zod/v4";
export const contracts = defineContracts({
enrichCompany: {
id: "k7m2x9pq",
input: z.object({ domain: z.string() }),
output: z.object({ website: z.url() })
}
});Mount the handler on a catch-all route under /api/mii/invoke, the way chat is
mounted, for GET and POST:
const invoke = createMiiInvokeHandler(auth, contracts);In the browser, type the client from the registry without bundling it:
import { createInvoker } from "@shubh90/app-runtime/invoke";
import type { contracts } from "~/contracts";
const { invoke, useInvocation } = createInvoker<typeof contracts>();
const { status, run, reset } = useInvocation("enrichCompany");
// run({ domain }); status.state is idle | pending | succeeded | failed, with
// elapsedMs and the invocation id. While pending, status.pollError says why the
// latest read failed (or null): the wait never gives up on its own.A press is one request that returns when the agent answers. The platform holds the
read for up to 60 seconds; if the answer takes longer than that, the client
quietly asks again, at once, for as long as it takes. The app's server holds
its own request just as long, so the route this handler is mounted on needs a
function limit of about 90 seconds: export const maxDuration = 90 on a Next
route, the function maxDuration in the Vercel config of a TanStack Start app.
A limit below the hold does not break a press, but each read then fails at the
limit and the client asks again after 1.5 seconds, so the status's pollError
flickers on with each retry while the press still completes.
Coming back to a result. A run carries on in the platform when the page that started it goes away; only the page forgets it. Name what the press is for and the hook finds it again:
const { status, run, reset } = useInvocation("fillColumn", { remember: `fill:${column}` });The run's id is kept in this browser's localStorage, so a reload or a
reopened tab picks it up: still going, it keeps waiting (with elapsedMs
counted from the start); finished, the result is there at once. status.input
is what the run was asked, so a page that picked it up can show what it is for
(which column, which text) without keeping anything itself. reset() is the
person being done with it; otherwise it is picked up until
INVOCATION_REMEMBER_MS after it started. A run the platform doesn't have for the
signed-in person (someone else's, in a shared browser) is let go of. Without
remember a run belongs to the mounted component, as before. resume(id)
follows a run by its id, such as one a server function started.
Starting from the app's server. When the start must be recorded with something else (a job row that names its run), start it in a POST server function (not a loader: a page load must not start agent work) as the person whose request it answers:
// src/lib/invoke.server.ts: the contracts served once, for the route and server functions
import { createMiiInvoke } from "@shubh90/app-runtime/invoke/server";
export const invoke = createMiiInvoke(auth, contracts);
// in a POST server function
const { id } = await invoke.start({
request: getRequest(), // from @tanstack/react-start/server: its cookie and origin are checked
key: "researchRecord",
input: { job: jobId, record }
});
// refusals reject with InvocationError (the codes a press gets).Reading a result on the server. A server function that needs a finished run's result (to act on it, or to check what a page sends back) reads it as the same person, typed and parsed by the app's own output schema:
const { company } = await invoke.result("enrichCompany", id, getRequest());It is one read, with no waiting: a run still going rejects with code
pending, one the agents failed with failed, and a run of another contract
with not_found.
createMiiInvoke(auth, contracts).handler is the invoke route; createMiiInvokeHandler
is the same route alone.
Ship order: 0.9.0 reads input on every invocation, so it needs a platform that
returns it; publish it only after that platform release is live.
Long jobs. For work that takes minutes or days, make the contract's output
a small acknowledgement, such as { accepted: true }, and have the prompt tell
the agent to call submit_result as soon as it has accepted the job, then keep
working and write its results and status into the app's own tables, which the
UI reads. After the acknowledgement the invocation no longer tracks the job.
The platform validates a result with ajv (JSON Schema draft 2020-12, strict) against
the JSON Schema form of output, and this handler then parses it with output
itself, which is where refinements and transforms apply: the app's own Zod
parse is the final check. createMiiInvokeHandler converts every output once,
when it is made, so an output with no JSON Schema form (z.date(), z.map())
stops the server starting. Every zod string format works; z.jwt() emits no
pattern, so only the app's own parse checks it.
Migration order (rollout)
See the plan doc. In short: publish @shubh90/app-runtime, convert one app on a
test org and prove sign-in in the pane, then a version-bump bot adopts it across
the fleet, then delete syncPlatformKit / PLATFORM_OWNED_PREFIXES and the
backfill-org-app-* scripts.
Parity with the kit it replaces
The auth logic is ported verbatim from the Next.js template's
src/lib/mii-auth (the template is gone; the kit the runner still syncs into
those apps lives in infra/provisioning/next-fleet); only the module-level
USERS/authSql singletons became a createMiiAuth argument, and the login
route's hard @sentry/nextjs import became the injected reportError. The
existing next-path and plaza unit tests are ported (test/), plus new
coverage for withMiiPlatform and the users config.
