React Hooks

@theaiplatform/miniapp-sdk/react wraps the platform API in React hooks, so a surface does not hand-roll loading state, failure classification, and refresh for every capability it touches.

import {
  useSpecialist,
  useTasks,
  useUser,
  useWorkspace,
  useWorkspaceMembers,
} from '@theaiplatform/miniapp-sdk/react';

Hooks live in their own entry rather than under ./ui, because ./ui is the design system. A surface that only wants a task list should not pull the component library.


Conventions

Every hook in this entry follows the same rules, so the per-hook sections below only note where one deviates.

What every hook returns

FieldTypeMeaning
isSupportedbooleanThe host exposes this capability. Hide the affordance when false.
isLoadingbooleanA first load is in flight. Show a skeleton once.
isBusybooleanAny read or write is in flight. Disable controls. Resource hooks only.
failure{ reason, message, detail? } | undefinedWhy the last attempt did not succeed. message is safe to display.
reload() => Promise<void>Re-read from the host.

Resource hooks add data for the collection, find(id) for a synchronous lookup in what is already loaded, and create / update / remove for writes.

No hook throws

Reading a capability the host has not installed reports isSupported: false rather than throwing, so a surface that renders before the host is ready degrades instead of crashing. This is why you must not test for support with Boolean(sdk.user): the SDK proxy throws on property access outside a supported environment, which is the opposite of what you want at a support check. Use the hook's isSupported.

denied is never inferred

failure.reason is an enumerated string. A withheld grant is always denied, and an unrecognized error is never reported as one — telling somebody "not permitted" during a backend outage sends them to fix a permission they already have.

reasonWhat happenedWhat to show
unsupported-hostThe host has no such capabilityNothing; hide the feature
deniedThe grant is withheld or revoked"Ask an admin to enable…"
request-failedThe host could not answer"Try again"

Nothing is passed in

Each hook reads the installed sdk itself, the workspace defaults to the one your surface is mounted in, and a specialist's model defaults to the workspace's choice. Every hook's options argument exists so a test can inject a fake; supplying a capability by hand in product code is almost always a mistake.

No shared cache yet

Each hook fetches independently, so two components calling useTasks fetch twice and can briefly disagree. Hoist the hook and pass results down when that matters. A shared cache is a deliberate open decision: adopting one changes every hook's semantics, so it should happen once rather than per hook.

Choosing between a hook and a direct call

A hook earns its place when there is state to manage, a subscription to hold, or a multi-step sequence to run. Wrapping everything is not a goal — every hook is public API that has to be maintained and versioned.

Use a hookCall the SDK directly
Resources you list and mutateOne-shot actions: sendTextToChat, openProject
Long-running work with statePure computation over data you already hold
Async reads a surface needs in renderThe same read from an event handler

Render cannot await. Without a hook, every surface hand-rolls the same useEffect + useState + "did this unmount first" + "was it denied or did it fail" for a single string. Outside render, call the SDK.


useUser

Reads the person using this surface.

Reference

useUser(options?)

Call useUser at the top level of your component to read the signed-in user.

function Greeting() {
  const { user, isSupported, isLoading } = useUser();

  if (!isSupported) return null;
  if (isLoading) return <Skeleton />;
  return <h1>Hello, {user?.displayName || 'there'}</h1>;
}

Parameters

  • options (optional):
    • user (optional): a MiniAppUserApi to read instead of the installed one. For tests and non-standard hosts.

Returns

  • user: { userId: string; displayName: string }, or undefined until the first read resolves.
  • isSupported, isLoading, failure, reload: see Conventions.

Caveats

  • Requires no permission and shows no prompt. Your mount context already carries userId, so the name attached to it is not new authority. Anyone else's identity is useWorkspaceMembers, which is granted separately.
  • displayName is host-derived. A package cannot supply or influence it.
  • displayName can be an empty string when the signed-in profile has no usable name, which a guest mount can produce. Render a fallback rather than an empty row.
  • The hook does not poll. An identity cannot change without a remount, so reload exists only to retry a failed first read.

Usage

Attributing something the person did

userId is the same id the rest of the platform uses, so it is what you store on records your backend keeps.

const { user } = useUser();

await fetch('/api/standup', {
  method: 'POST',
  body: JSON.stringify({ author: user?.userId, text }),
});

Prefer deriving the author on your backend from the verified session where you can. The client value is convenient for display; it is not proof of who is calling.


useWorkspace

Reads the workspace this surface is mounted in.

Reference

useWorkspace(options?)

function Header() {
  const { workspace } = useWorkspace();
  return <h1>{workspace?.displayName ?? 'Pulse'}</h1>;
}

Parameters

  • options (optional):
    • workspace (optional): a MiniAppWorkspaceApi to read instead of the installed one.

Returns

  • workspace: { workspaceId: string; displayName: string }, or undefined until the first read resolves.
  • isSupported, isLoading, failure, reload: see Conventions.

Caveats

  • Requires a workspace.read action at listen autonomy.
  • displayName falls back to the workspace id rather than an empty string, so there is always something to print.
  • The hook does not poll. A workspace is not renamed out from under a mounted surface often enough to justify it.
  • A denied reload clears workspace, so a revoked grant does not leave the name on screen.

Usage

Titling a surface without printing an id

A header that reads org_df93b96b-e1f9-43a0-a8c5-5c897e5ae15b tells nobody anything. Prefer the workspace's name, and keep your package name as the fallback:

const { workspace } = useWorkspace();
const title = workspace?.displayName ?? 'Pulse';

useWorkspaceMembers

Reads the workspace roster — joined members only.

Reference

useWorkspaceMembers(options?)

function Team() {
  const { members, isSupported, failure } = useWorkspaceMembers();

  if (!isSupported || failure?.reason === 'denied') return <SubmissionsOnly />;
  return (
    <ul>
      {members.map((member) => (
        <li key={member.userId}>{member.displayName}</li>
      ))}
    </ul>
  );
}

Parameters

  • options (optional):
    • workspaceId (optional): defaults to the mounted workspace. A different workspace is refused by the host.
    • enabled (optional): false skips the read entirely, for a surface that only needs the roster conditionally.
    • workspace (optional): a MiniAppWorkspaceApi to read instead of the installed one.

Returns

  • members: { userId: string; displayName: string }[]. Empty until the first read resolves, and empty when the grant is withheld.
  • isSupported, isLoading, failure, reload: see Conventions.

Caveats

  • Requires workspace.read-members at listen — deliberately not the workspace.read grant that covers listTeams, listProjects and useWorkspace. Those describe how work is organised; this describes people, most of whom never opened your package, so it is granted and revoked on its own.
  • Joined members only. An invitation is not a teammate, and pending invites would reveal hiring before it is announced.
  • Each member is a user id and a name, and nothing else. The host's roster also carries email, role, title, timezone, invitation timestamps and who invited whom; none of it crosses this boundary.
  • A denied reload clears members, so a revoked grant cannot leave names rendered.
  • There is no avatar field, and adding one would not help: the generated surface CSP is img-src 'self' data:, so a remote image cannot load whatever your manifest declares. Draw initials, or have your own backend inline bytes as a data: URI.

Usage

Showing everyone, not only the people who used your app

This is the reason the hook exists. A miniapp that treats "people who have used me" as "the team" under-reports silently: someone who never opened it is invisible rather than missing, and any count you show is against the wrong denominator.

Join the roster to your own records, and keep the roster as the denominator:

const { members } = useWorkspaceMembers();
const submissions = useSubmissions(); // your backend

const rows = members.map((member) => ({
  member,
  submission: submissions.find((entry) => entry.userId === member.userId),
}));
const submitted = rows.filter((row) => row.submission).length;

return <Header count={`${submitted} of ${members.length}`} rows={rows} />;

Keeping a submission from someone who has left

A record whose author is no longer in members is not a bug to hide. Their week happened; dropping it silently revises history. Append the orphans instead of discarding them, and label them.

Degrading when the grant is withheld

denied is a normal state, not an error. Render what you can:

const { members, failure } = useWorkspaceMembers();

// No roster: show submissions alone rather than an error screen.
if (failure?.reason === 'denied') return <SubmissionsOnly />;

Troubleshooting

The roster is empty, but the workspace clearly has members

Check failure.reason before assuming the read worked. An empty array is also what a withheld grant returns, and the two are indistinguishable without it:

const { members, failure, isLoading } = useWorkspaceMembers();
console.log({ count: members.length, reason: failure?.reason, isLoading });

If reason is denied, your package declares workspace.read-members but the installation has not granted it. If reason is unsupported-host, the host predates the capability.

It worked before the host update and now reports denied

workspace.read-members is a newer action than workspace.read. An installation that predates it holds no grant for it, and grants are captured at install rather than re-derived — so the roster is refused until the package is reinstalled or the grant is added. That is deliberate: an existing grant must not silently widen into enumerating people.


useSpecialist

Runs a turn against a specialist your package declares. See Specialists for the declaration contract and the channel-versus-room model.

Reference

useSpecialist(specialistIdOrOptions)

function DraftButton() {
  const drafter = useSpecialist('standup-drafter@0.2.0');

  if (!drafter.isSupported) return null;
  return (
    <>
      <button
        disabled={drafter.isRunning}
        onClick={() => drafter.run('Draft my standup.')}
      >
        {drafter.isRunning ? 'Drafting…' : 'Draft'}
      </button>
      {drafter.failure && <p role="alert">{drafter.failure.message}</p>}
      {drafter.data && (
        <Draft value={drafter.data} onRetry={drafter.regenerate} />
      )}
    </>
  );
}

Parameters

  • specialistIdOrOptions: the slug@version string, or an options object:
    • specialistId: the slug@version to run.
    • parse (optional): types the result. Return null or undefined to reject an answer as off-contract — both conventions work, so a parser using either needs no adapter. Omit it and data is the raw text.

Returns

  • data: the parsed result, or the raw text when parse is omitted.
  • status: unsupported | idle | running | ready | failed.
  • isRunning: status === 'running'.
  • run(prompt), regenerate(), reset(): stable for the hook's whole life, so they are safe in a dependency array and as a memoized child's prop.
  • isSupported, failure: see Conventions.

Caveats

  • A failure outranks a previous answer, so a failed regenerate never leaves a stale result presented as current.
  • While a turn is in flight, run is a no-op — matching the host, which serializes turns on one room. reset() does not let a second turn start while the host is still working on the first.
  • There is no cancellation. The host offers a guest no way to stop a turn it started, and an abort() that only stopped the caller listening would be misleading while the turn kept running.

Usage

Running several specialists

Call the hook once per specialist — the same shape as one query per key. Each gets its own room keyed on (workspace, package, specialist), so they neither share history nor queue behind each other.

const evaluator = useSpecialist({ ...shared, specialistId: evaluatorId });
const stakeholder = useSpecialist({ ...shared, specialistId: stakeholderId });

Outside React

The same lifecycle without React is runSpecialist from @theaiplatform/miniapp-sdk/sdk.


useTasks

Lists and mutates tasks.

Reference

useTasks(options?)

const {
  data: tasks,
  isLoading,
  isBusy,
  failure,
  find,
  create,
  update,
  remove,
} = useTasks();

Returns

  • data: the loaded tasks.
  • find(id): a synchronous lookup in already-loaded data. Tasks have no by-id read, so this does not fetch.
  • create / update / remove: resolve with the affected item, or undefined/false on failure with the cause in failure.
  • isSupported, isLoading, isBusy, failure, reload: see Conventions.

Caveats

  • useTasks follows every host page before it publishes data.
  • Writes reload rather than patching local state. The host owns task shape — status normalization, assignee resolution, archived transitions — so echoing a locally-mutated task risks showing something the host would describe differently.
  • isLoading covers the first load, for a skeleton. isBusy covers any read or write, for disabling controls.
  • remove, not delete: delete is a reserved word, so const { delete } = useTasks() will not parse.
  • data, not tasks: one shared shape across hooks. Rename at the call site where a domain name reads better — const { data: tasks } = useTasks().

Troubleshooting

find returns undefined for a task I know exists

find searches loaded data. If the task was created outside this surface, reload() first. Not every resource supports every operation, because the platform APIs do not: tasks have a list but no by-id read, and projects have a by-id read but no list. A missing method means a missing platform operation rather than an oversight.