@mexty/ai
v0.1.0
Published
Run real language models from a Mexty block, through the activity contract its author set
Readme
@mexty/ai
Run real language models from a Mexty block — Claude, GPT, Gemini and Mistral — under a contract the block's author set. The block sends a conversation; the platform decides which model runs, whether the budget allows it, and what it costs. No API key ever reaches the browser, and a block cannot pick a model its author did not allow.
This is the door for AI-literacy activities: "ask the same question to four
models", "compare answers", "see what a prompt change does". If you want
persistent data, that is @mexty/database; live shared state is
@mexty/multiplayer. The three work together.
Before you write any code
Linking an AI activity to a block is Mexty staff only, and it does not happen in code. A staff member creates the activity (which providers and tiers it may run, its system prompt, its output cap, its budgets) and links the block to it. This package reads whatever activity id ended up in the block's props.
Two things are required:
1. Declare the property in your BlockProps interface, verbatim:
/** Mexty AI activity this block runs models through. Staff-managed. */
aiActivityId?: string;2. Add the dependency. The block scaffold does not ship it:
"dependencies": {
"@mexty/ai": "^0.1.0"
}A range, deliberately, for the same reason @mexty/database uses one: the
fixes that matter here are client-side (a session that stops renewing, a
stream format change), and an exact pin would mean editing every block to
receive them.
⚠️ What the learner sees, the model sees
Whatever text the block puts in messages is sent to a third-party model
provider. Do not put other learners' data, saved answers, or anything the
learner did not type or explicitly choose into a prompt. The activity's
system prompt is the author's and is added server-side; a block cannot set or
override it.
Usage
One answer from one model
import { useMextyAi, useAiRun } from '@mexty/ai';
function AskBlock({ aiActivityId }: BlockProps) {
const ai = useMextyAi(aiActivityId);
const { text, isStreaming, result, error, run } = useAiRun(ai, { provider: 'gemini' });
return (
<>
<button onClick={() => run('Why is the sky blue?')} disabled={isStreaming}>Ask</button>
<p>{text}</p>
{result && <small>{result.metadata.modelId} · {result.metadata.usage?.totalTokens} tokens</small>}
{error && <p role="alert">{error.message}</p>}
</>
);
}text fills in as the answer streams. result is set when it finishes and
carries the run id, the model that actually ran, token usage and the cost in
credits.
Compare models — one hook per model
const ai = useMextyAi(aiActivityId);
const claude = useAiRun(ai, { provider: 'claude' });
const gpt = useAiRun(ai, { provider: 'gpt' });
const gemini = useAiRun(ai, { provider: 'gemini', tier: 'fast' });
const askAll = (q: string) => { claude.run(q); gpt.run(q); gemini.run(q); };Each instance has its own text, streaming state and cost. Only providers and tiers the activity allows will run — anything else answers with a 400 the block can show.
Let the learner pick
import { useAiModels } from '@mexty/ai';
const { models, loading } = useAiModels(ai);
// models: [{ provider, tier, modelId, label, isDefault }, …] — what this block may run right nowA conversation
Pass the history instead of a string; it must end on the learner's turn.
run([
{ role: 'user', content: 'What is a neural network?' },
{ role: 'assistant', content: previousAnswer },
{ role: 'user', content: 'Explain it to a 10-year-old.' },
]);Handling the states a block can be in
const ai = useMextyAi(props.aiActivityId);
if (ai.status === 'unauthenticated') return <SignInPrompt />;| status | meaning |
|---|---|
| ready | linked and reachable — runs work |
| no-activity | no activity id was given to this block |
| sandbox | the block is not running with a blockId |
| unauthenticated | this activity needs a signed-in viewer and there is none |
Render normally on all four. When the client is not ready, run rejects
with an AiRunError whose reason is the status and useAiRun puts it in
error; models() resolves empty. A block whose activity was never linked
still renders — which is its normal state in the marketplace, and after
anyone forks it.
When a run is refused
error.status is the HTTP status and error.reason says why:
| reason | meaning |
|---|---|
| owner-credits | the activity's owner is out of credits |
| learner-budget | this learner has used their share of the activity |
| activity-budget | the activity's own budget is exhausted |
| aborted | stop() was called |
| stream | the model call failed mid-answer |
Show these; do not retry them in a loop.
API
useMextyAi(activityId?)
The client for this block. Recreated only when the id changes, so it is safe
to call straight from props. Re-renders when status changes.
useAiRun(ai, { provider?, tier? })
{ text, isStreaming, result, metadata, error, run, stop, reset }.
run(input, overrides?) takes a string (one learner turn) or a message list
and returns the result, or null if it was stopped or refused. Starting a run
stops the one in flight.
useAiModels(ai)
{ models, loading, error, reload }.
The client directly
const ai = createAiClient(activityId);
await ai.models(); // AiModel[]
await ai.run({ provider, tier?, messages, signal?, onDelta?, onStart? }); // { text, metadata }
ai.status; // see above
ai.context; // blockId, shareToken… from the URLSemantics & limits
- Providers are
claude | gpt | gemini | mistral; tiers arefrontier | flagship | balanced | fast. Which pairs run is the activity's decision; the concrete model behind a pair is resolved by the platform at call time from what it currently offers, so a block never breaks because a model was retired. - Messages are plain text,
user/assistantonly, at most 40 turns, ending onuser. Total characters are capped per activity (8,000 by default); the server answers 400 with the limit when exceeded. - Output is capped per activity (1,024 tokens by default). A short answer is the intended shape.
- Every run is billed to the activity's owner and counted against the activity's budget and the learner's share. A refused run costs nothing.
- Anonymous learners are admitted only when the activity allows it, under
the same anonymous id
@mexty/databaseuses — so a teacher's ledger lines up with the class's saved rows. - A
429is retried with backoff. An expired session is renewed once and the request replayed;unauthenticatedmeans the renewal failed too. - Do NOT call
configure()on the platform. The server URL is resolved from the page's hostname; overriding it points a block at the wrong environment.
Development
npm install
npm run typecheck
npm test
npm run buildThe stream fixtures under src/__fixtures__ were produced by the backend's
own ai@5 and are what a block receives byte for byte. Regenerate them when
the run route's stream changes.
