@agentick/core
Core engine for Agentick. Provides the React-like reconciler, JSX components, and hooks for building LLM applications.
Installation
pnpm add @agentick/core
Quick Start
import { createApp, System, Timeline, Message, createTool } from "@agentick/core";
import { createOpenAIModel } from "@agentick/openai";
import { z } from "zod";
const Calculator = createTool({
name: "calculator",
description: "Performs math calculations",
input: z.object({ expression: z.string() }),
handler: async ({ expression }) => {
const result = eval(expression);
return [{ type: "text", text: String(result) }];
},
});
function MyApp() {
return (
<>
<System>You are a helpful assistant with access to a calculator.</System>
<Timeline />
<Calculator />
</>
);
}
const app = createApp(MyApp, { model: createOpenAIModel() });
const session = await app.session();
await session.send({
messages: [{ role: "user", content: [{ type: "text", text: "What is 2 + 2?" }] }],
}).result;
Level 0: createAgent (No JSX Required)
For simple agents that don't need custom rendering or hooks:
import { createAgent, knob } from "@agentick/core";
import { createOpenAIModel } from "@agentick/openai";
const agent = createAgent({
system: "You are a helpful researcher.",
model: createOpenAIModel(),
tools: [SearchTool, Calculator],
knobs: {
mode: knob("broad", { description: "Search mode", options: ["broad", "deep"] }),
},
});
const handle = await agent.run({
messages: [{ role: "user", content: [{ type: "text", text: "Research quantum computing" }] }],
});
for await (const chunk of handle) {
console.log(chunk);
}
const result = await handle.result;
const session = await agent.session();
await session.send({
messages: [{ role: "user", content: [{ type: "text", text: "Research quantum computing" }] }],
}).result;
createAgent wraps the <Agent> component and createApp — same capabilities, no JSX. For conditional tools, custom hooks, or composition, use <Agent> directly (Level 1+):
import { Agent, createApp } from "@agentick/core";
function MyAgent() {
const [verbose] = useKnob("verbose", false, { description: "Verbose mode" });
return <Agent system="You are helpful." tools={[SearchTool]} />;
}
const app = createApp(MyAgent);
JSX Components
<System>
Define system instructions:
<System>You are a helpful assistant.</System>
<Timeline>
Render conversation history. This is the core component that represents the conversation:
<Timeline />
<Timeline roles={['user', 'assistant']} limit={10} />
<Timeline maxTokens={4000} strategy="sliding-window" headroom={500} />
<Timeline maxTokens={8000}>
{(entries, pending, budget) => {
if (budget?.isCompacted) console.log(`Evicted ${budget.evictedCount} entries`);
return entries.map(entry => <Message key={entry.id} entry={entry} />);
}}
</Timeline>
<Timeline.Provider>
<Timeline.Messages renderEntry={(entry) => <CustomMessage entry={entry} />} />
</Timeline.Provider>
Token Budget Compaction
When maxTokens is set, Timeline automatically compacts entries that exceed the token budget. Entries carry token estimates from the compiler's annotation pass (or fall back to a char/4 heuristic).
maxTokens | number | — | Token budget. Enables compaction when set. |
strategy | CompactionStrategy | "sliding-window" | Compaction strategy |
headroom | number | 0 | Reserve tokens for safety margin |
preserveRoles | string[] | ["system"] | Roles that are never evicted |
onEvict | (entries) => void | — | Callback when entries are evicted |
guidance | string | — | Passed to custom strategy functions |
Built-in strategies:
"sliding-window" (default): Preserves entries with protected roles, then fills remaining budget with newest entries. Maintains original entry order.
"truncate": Keeps newest entries that fit. Simple FIFO eviction.
"none": No compaction. Entries pass through unchanged.
- Custom function:
(entries, budget, guidance?) => { kept, evicted }
Budget info is available via render prop (3rd argument) or useTimelineContext().budget:
interface TokenBudgetInfo {
maxTokens: number;
effectiveBudget: number;
currentTokens: number;
evictedCount: number;
isCompacted: boolean;
}
<Message>
Add messages to the conversation:
<Message role="user">Hello!</Message>
<Message role="assistant">Hi there!</Message>
<Message role="user">
<Text>Check this image:</Text>
<Image source={{ type: "url", url: "https://..." }} />
</Message>
<Section>
Group content with semantic meaning:
<Section id="context" title="Current Context">
Today is {new Date().toDateString()}. User is logged in as {user.name}.
</Section>
<Model>
Override the model for a subtree. Also accepts generation parameters and response format:
<Model model={gpt4oMini}>
{}
</Model>
<Model model={gpt4o} responseFormat={{ type: "json" }} />
<Model
model={gpt4o}
responseFormat={{
type: "json_schema",
schema: { type: "object", properties: { name: { type: "string" } } },
name: "person",
}}
temperature={0.2}
/>
ResponseFormat
Normalized across providers. Three modes:
{ type: "text" } | Free-form text (default) |
{ type: "json" } | Valid JSON output |
{ type: "json_schema", schema, name? } | JSON conforming to a JSON Schema |
For Zod schemas, call zodToJsonSchema() yourself — Agentick doesn't bundle Zod.
<Markdown> / <XML>
Control output formatting:
<Markdown>
<Section id="rules">
- Rule 1
- Rule 2
</Section>
</Markdown>
<XML>
<Section id="data" title="User Data">
{JSON.stringify(userData)}
</Section>
</XML>
Hooks
State Hooks
import { useState, useSignal, useComputed, useComState } from "@agentick/core";
function MyComponent() {
const [count, setCount] = useState(0);
const counter = useSignal(0);
const doubled = useComputed(() => counter() * 2, [counter]);
counter();
counter.set(5);
counter.update((v) => v + 1);
doubled();
const notes = useComState<string[]>("notes", []);
notes();
notes.set(["a", "b"]);
}
Lifecycle Hooks
All lifecycle hooks follow the pattern: data first, COM (context) last.
import {
useOnMount,
useOnUnmount,
useOnTickStart,
useOnTickEnd,
useAfterCompile,
useContinuation,
} from "@agentick/core";
function MyComponent() {
useOnMount((ctx) => {
console.log("Component mounted");
});
useOnUnmount((ctx) => {
console.log("Component unmounting");
});
useOnTickStart((tickState) => {
console.log(`Tick ${tickState.tick} starting...`);
});
useOnTickEnd((result) => {
console.log(`Tick ${result.tick} complete, tokens: ${result.usage?.totalTokens}`);
});
useAfterCompile((compiled) => {
console.log(`Compiled ${compiled.tools.length} tools`);
});
useContinuation((result) => {
if (result.text?.includes("<DONE>")) return { stop: true, reason: "done" };
if (result.tick >= 10) return false;
});
}
Message Hooks
import { useQueuedMessages, useOnMessage } from "@agentick/core";
function MyComponent() {
const queuedMessages = useQueuedMessages();
useOnMessage((message, ctx, state) => {
console.log("Received:", message);
});
}
Context Hooks
import { useCom, useTickState, useContextInfo } from "@agentick/core";
function MyComponent() {
const ctx = useCom();
const history = ctx.timeline;
const tickState = useTickState();
console.log(`Tick ${tickState.tick}`);
const contextInfo = useContextInfo();
if (contextInfo) {
console.log(`Model: ${contextInfo.modelId}`);
console.log(`Tokens: ${contextInfo.inputTokens} in / ${contextInfo.outputTokens} out`);
console.log(`Utilization: ${contextInfo.utilization?.toFixed(1)}%`);
}
}
Knobs
Knobs are form controls for models. The same way HTML inputs bridge humans to application state, knobs bridge models to application state. The model sees primitive values (string, number, boolean), can change them via a set_knob tool, and the change takes effect on the next recompile.
useKnob() creates reactive state + renders it to model context + registers a tool — all in one line.
import { useKnob, Knobs } from "@agentick/core";
function Agent() {
const [mode] = useKnob("mode", "broad", {
description: "Operating mode",
options: ["broad", "deep"],
});
const [temp] = useKnob("temp", 0.7, {
description: "Temperature",
group: "Model",
min: 0,
max: 2,
step: 0.1,
});
const [verbose] = useKnob("verbose", false, { description: "Verbose output" });
const [model] = useKnob("model", "gpt-4", { description: "Model" }, (v) => openai(v));
return (
<>
<Knobs />
<Timeline />
</>
);
}
<Knobs /> — Three Rendering Modes
The set_knob tool is always registered automatically. You control how knobs are rendered to the model's context:
<Knobs />
<Knobs>
{(groups) => (
<Section id="my-knobs" audience="model">
{groups.flatMap(g => g.knobs).map(k => `${k.name}=${k.value}`).join("\n")}
</Section>
)}
</Knobs>
<Knobs.Provider>
<Knobs.Controls /> {/* Default section */}
<Knobs.Controls renderKnob={(k) => ...} /> {/* Custom per-knob */}
<Knobs.Controls renderGroup={(g) => ...} /> {/* Custom per-group */}
</Knobs.Provider>
The provider pattern also exposes useKnobsContext() for fully custom rendering:
import { useKnobsContext } from "@agentick/core";
function MyKnobUI() {
const { knobs, groups, get } = useKnobsContext();
const temp = get("temp");
return (
<Section id="knobs" audience="model">
Temperature is {temp?.value}. There are {knobs.length} knobs.
</Section>
);
}
Config-level Knobs
For createAgent / <Agent>, declare knobs as descriptors with knob():
import { knob, createAgent } from "@agentick/core";
const agent = createAgent({
system: "You are a researcher.",
knobs: {
mode: knob("broad", { description: "Operating mode", options: ["broad", "deep"] }),
temperature: knob(0.7, { description: "Temperature", min: 0, max: 2, step: 0.1 }),
},
});
See packages/core/src/hooks/README.md for complete API reference including KnobInfo, KnobGroup, constraints, and validation.
Context Utilization
The useContextInfo hook provides real-time information about model context usage:
import { useContextInfo, type ContextInfo } from "@agentick/core";
function ContextAwareComponent() {
const contextInfo = useContextInfo();
if (!contextInfo) {
return <Section id="status">Waiting for first response...</Section>;
}
const { modelId, modelName, provider } = contextInfo;
const { inputTokens, outputTokens, totalTokens } = contextInfo;
const { utilization, contextWindow } = contextInfo;
const { supportsVision, supportsToolUse, isReasoningModel } = contextInfo;
const { cumulativeUsage } = contextInfo;
if (utilization && utilization > 80) {
return <System>Be concise - context is running low.</System>;
}
return null;
}
ContextInfo Interface
interface ContextInfo {
modelId: string;
modelName?: string;
provider?: string;
contextWindow?: number;
maxOutputTokens?: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
utilization?: number;
supportsVision?: boolean;
supportsToolUse?: boolean;
isReasoningModel?: boolean;
tick: number;
cumulativeUsage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
ticks: number;
};
}
Using ContextInfoProvider (Advanced)
For custom setups, you can create and provide your own context info store:
import { createContextInfoStore, ContextInfoProvider } from "@agentick/core";
const contextInfoStore = createContextInfoStore();
<ContextInfoProvider store={contextInfoStore}>
<MyApp />
</ContextInfoProvider>;
contextInfoStore.update({
modelId: "gpt-4o",
inputTokens: 1500,
outputTokens: 500,
totalTokens: 2000,
tick: 1,
});
const current = contextInfoStore.current;
Tools
Create tools the model can call:
import { createTool } from "@agentick/core";
import { z } from "zod";
const WeatherTool = createTool({
name: "get_weather",
description: "Get current weather for a location",
input: z.object({
location: z.string().describe("City name"),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
handler: async ({ location, units }, ctx) => {
const weather = await fetchWeather(location, units);
ctx?.setState("lastLocation", location);
return [{ type: "text", text: JSON.stringify(weather) }];
},
render: (tickState, ctx) => <Section id="weather-info">Last checked: {lastChecked}</Section>,
});
const ShellTool = createTool({
name: "shell",
description: "Execute a command in the sandbox",
input: z.object({ command: z.string() }),
use: () => ({ sandbox: useSandbox() }),
handler: async ({ command }, deps) => {
const result = await deps!.sandbox.exec(command);
return [{ type: "text", text: result.stdout }];
},
});
function App() {
return (
<>
<System>You can check the weather.</System>
<Timeline />
<WeatherTool />
</>
);
}
Prop Overrides
Customize pre-built tool metadata via JSX props without creating new tools:
<WeatherTool description="Check weather. Always include units." />
<ShellTool name="bash" requiresConfirmation={true} />
Tool Sources
Tools merge from four sources (lowest → highest priority):
const app = createApp(Agent, { tools: [SearchTool] });
const session = await app.session({ tools: [FileTool] });
await session.send({ messages: [...], tools: [DynamicTool] });
function Agent() {
return <SearchTool description="Custom desc" />;
}
On each tick, tools merge in order: app → session → execution → JSX. Last-in wins by name.
Spawned sessions (session.spawn()) start fresh — they do not inherit parent tools. Each child defines its own toolset via JSX and app-level tools.
App & Session
Creating an App
import { createApp } from "@agentick/core";
const app = createApp(MyApp, {
model: myModel,
devTools: true,
});
Basic Options
const app = createApp(MyApp, {
model: createOpenAIModel(),
maxTicks: 10,
devTools: true,
tools: [ExternalTool],
mcpServers: { ... },
runner: myRunner,
});
Lifecycle Callbacks
Callbacks provide a cleaner alternative to event listeners:
const app = createApp(MyApp, {
model,
onTickStart: (tick, executionId) => console.log(`Tick ${tick}`),
onTickEnd: (tick, usage) => console.log(`Used ${usage?.totalTokens} tokens`),
onComplete: (result) => console.log(`Done: ${result.response}`),
onError: (error) => console.error(error),
onEvent: (event) => {
},
onBeforeSend: (session, input) => {
},
onAfterSend: (session, result) => {
},
onToolConfirmation: async (call, message) => {
return await askUser(`Allow ${call.name}?`);
},
});
Session Management
const session = await app.session();
const session = await app.session("user-123");
const result = await session.send({
messages: [{ role: "user", content: [{ type: "text", text: "Hello!" }] }],
}).result;
const snapshot = session.snapshot();
console.log(snapshot.timeline);
console.log(snapshot.usage);
Spawning Child Sessions
session.spawn() creates an ephemeral child session with a different agent/component. The child runs to completion and returns a SessionExecutionHandle — the same type as session.send(). This is the recursive primitive for multi-agent systems.
const handle = await session.spawn(ChildAgent, {
messages: [{ role: "user", content: [{ type: "text", text: "Analyze this data" }] }],
});
const result = await handle.result;
const handle = await session.spawn(
{ system: "You are a summarizer.", model: summaryModel },
{ messages: [{ role: "user", content: [{ type: "text", text: doc }] }] },
);
const handle = await session.spawn(<Researcher query="quantum computing" />, {
messages: [{ role: "user", content: [{ type: "text", text: "Go" }] }],
});
Parallel spawns work with Promise.all:
const [researchResult, factCheckResult] = await Promise.all([
session.spawn(Researcher, { messages }).then((h) => h.result),
session.spawn(FactChecker, { messages }).then((h) => h.result),
]);
From tool handlers via ctx.spawn():
const DelegateTool = createTool({
name: "delegate",
description: "Delegate to a specialist",
input: z.object({ task: z.string() }),
handler: async (input, ctx) => {
const handle = await ctx!.spawn(Specialist, {
messages: [{ role: "user", content: [{ type: "text", text: input.task }] }],
});
const result = await handle.result;
return [{ type: "text", text: result.response }];
},
});
Key behaviors:
- Isolation: Child gets a fresh COM — no parent state leaks.
- Lifecycle isolation: Parent's lifecycle callbacks (onComplete, onTickStart, etc.) do NOT fire for child executions.
- Abort propagation: Aborting the parent execution aborts all children.
- Close propagation: Closing the parent session closes all children.
- Depth limit: Maximum 10 levels of nesting (throws if exceeded).
- Cleanup: Children are removed from
session.children when they complete.
Session Persistence
Sessions auto-persist after each execution and auto-restore when accessed via app.session(id).
const app = createApp(MyApp, {
model,
sessions: {
store: "./data/sessions.db",
maxActive: 100,
idleTimeout: 5 * 60 * 1000,
},
});
How it works:
- After each execution, session state is auto-saved to the store (fire-and-forget — persist failures don't block execution)
- When
app.session("user-123") is called and the session isn't in memory, it's auto-restored from the store
useComState and useData values are included in snapshots by default (set { persist: false } to exclude)
maxActive and idleTimeout control memory — evicted sessions can be restored from store
Snapshot Contents
A SessionSnapshot captures:
timeline | COMTimelineEntry[] ∣ null | Full conversation history |
comState | Record<string, unknown> | All useComState values (with persist !== false) |
dataCache | Record<string, ...> | All useData cached values (with persist !== false) |
tick | number | Tick count at snapshot time |
usage | UsageStats | Accumulated token usage |
Lifecycle Hooks
const app = createApp(MyApp, {
model,
sessions: { store: "./sessions.db" },
onBeforePersist: (session, snapshot) => {
if (snapshot.tick < 2) return false;
},
onAfterPersist: (sessionId, snapshot) => {
console.log(`Saved session ${sessionId}`);
},
onBeforeRestore: (sessionId, snapshot) => {
if (snapshot.version !== "1.0") return migrateSnapshot(snapshot);
},
onAfterRestore: (session, snapshot) => {
console.log(`Restored session ${session.id} at tick ${snapshot.tick}`);
},
});
Restore Layers
Layer 1 (default): Snapshot data is auto-applied. Timeline, comState, and dataCache are restored directly. Components see their previous state via useComState and useData.
Layer 2 (resolve): When resolve is configured, auto-apply is disabled. Resolve functions control reconstruction and receive the snapshot as context. Results are available via useResolved(key).
const app = createApp(MyApp, {
model,
sessions: { store: "./sessions.db" },
resolve: {
greeting: (ctx) => `Welcome back! You were on tick ${ctx.snapshot?.tick}`,
userData: async (ctx) => fetchUser(ctx.sessionId),
},
});
function MyAgent() {
const greeting = useResolved<string>("greeting");
const userData = useResolved<User>("userData");
}
Context Management vs History
The session's _timeline is the append-only historical log — it grows with every message. The <Timeline> component controls what the model sees (context) via its props:
<Timeline />
<Timeline limit={20} />
<Timeline maxTokens={8000} strategy="sliding-window" headroom={500} />
<Timeline roles={['user', 'assistant']} />
The useTimeline() hook provides direct access for advanced patterns:
function MyAgent() {
const timeline = useTimeline();
console.log(timeline.entries.length);
timeline.set([summaryEntry, ...recentEntries]);
timeline.update((entries) => entries.filter((e) => e.message.role !== "system"));
}
maxTimelineEntries — OOM Safety Net
For long-running sessions, maxTimelineEntries prevents unbounded memory growth by trimming the oldest entries after each tick. This is a safety net, not a context management strategy — use <Timeline> props for context control.
const app = createApp(MyApp, {
model,
maxTimelineEntries: 500,
});
Procedures & Middleware
Session methods send, render, queue, and spawn are all Procedures. This means they support middleware, context injection, and the chainable API:
const session = await app.session();
const handle = await session.send({ messages: [...] });
const result = await session.send({ messages: [...] }).result;
const handle = await session.render.use(async (args, envelope, next) => {
console.log("before render");
const result = await next();
console.log("after render");
return result;
})({ query: "Hello" });
const loggedRender = session.render.use(loggingMiddleware);
await loggedRender({ query: "test" }).result;
ProcedurePromise supports .result chaining — await proc().result resolves to the final SendResult regardless of whether the procedure is passthrough or handle-wrapped.
app.run is also a Procedure:
const handle = await app.run({ messages: [...], props: { query: "Hello" } });
await handle.result;
Middleware Inheritance
Apps inherit from the global Agentick instance by default:
import { Agentick, createApp } from "@agentick/core";
Agentick.use("*", loggingMiddleware);
Agentick.use("tool:*", authMiddleware);
const app = createApp(MyApp, { model });
const testApp = createApp(TestApp, {
model,
inheritDefaults: false,
});
Standalone Run
For one-off executions without session management:
import { run } from "@agentick/core";
const result = await run(<MyApp />, {
messages: [{ role: "user", content: [{ type: "text", text: "Hello!" }] }],
model: myModel,
});
Choosing run() vs createApp
| One-shot, quick prototype | run(<Agent />, { model, messages }) |
| Reusable app, persistent sessions | createApp(Agent, { model }) + app.run() / session.send() |
| Middleware, lifecycle hooks | createApp (supports app.run.use(mw)) |
run() accepts a JSX element (not a bare component function). Element props are defaults — input.props overrides them:
await run(<Agent query="default" />, { props: { query: "override" }, model, messages });
createApp takes a component function and returns a reusable app with session management, persistence, and middleware support.
Execution Runners
An ExecutionRunner controls the execution backend — how compiled context reaches the model and how tool calls are routed. The default is the standard model → tool_use protocol. Swap in a different runner to change the entire execution model without touching your agent code.
import { createApp, type ExecutionRunner } from "@agentick/core";
const replRunner: ExecutionRunner = {
name: "repl",
transformCompiled(compiled, tools) {
const commandList = tools
.map((t) => `- ${t.metadata?.name}: ${t.metadata?.description}`)
.join("\n");
return {
...compiled,
tools: [executeToolSchema],
system: [...compiled.system, { content: `Available commands:\n${commandList}` }],
};
},
async executeToolCall(call, tool, next) {
if (call.name === "execute") {
return sandbox.run(call.input.code);
}
return next();
},
onSessionInit(session) {
sandbox.create(session.id);
},
onDestroy(session) {
sandbox.destroy(session.id);
},
onPersist(session, snapshot) {
return { ...snapshot, comState: { ...snapshot.comState, _sandbox: sandbox.state() } };
},
onRestore(session, snapshot) {
sandbox.restore(snapshot.comState._sandbox);
},
};
const app = createApp(MyAgent, { model, runner: replRunner });
The agent's JSX — its <System>, <Timeline>, <Tool> components — stays identical. The runner transforms how that compiled context is consumed and how tool calls execute. This means you can build one agent and run it against multiple backends: standard tool_use for production, a sandboxed REPL for code execution, a human-in-the-loop gateway for approval workflows.
Interface
All methods are optional. Omitted methods use default behavior.
transformCompiled | Transform compiled context before the model sees it | Per tick |
executeToolCall | Intercept, transform, or replace tool execution | Per tool call |
onSessionInit | Set up per-session resources (sandbox, workspace) | Once |
onPersist | Add runner state to session snapshot | Per save |
onRestore | Restore runner state from snapshot | Once |
onDestroy | Clean up resources | Once |
Use Cases
- REPL/Code Execution: Replace tool schemas with command descriptions, route
execute calls to a sandboxed runtime, persist sandbox state across sessions.
- Human-in-the-Loop: Transform tool calls into approval requests, gate execution on human confirmation, log decisions.
- Sandboxing: Run tools in isolated containers, inject security boundaries, audit tool invocations.
- Testing: Intercept specific tools to return canned responses, track all lifecycle calls for assertions. See
createTestRunner() in @agentick/core/testing.
DevTools Integration
Enable DevTools for debugging:
const app = createApp(MyApp, {
devTools: true,
devTools: {
enabled: true,
remote: true,
remoteUrl: "http://localhost:3001/api/devtools",
},
});
For debugging the reconciler itself with React DevTools:
import { enableReactDevTools } from "@agentick/core";
enableReactDevTools();
Local Transport
createLocalTransport(app) bridges an in-process App to the ClientTransport interface. This enables @agentick/client (and @agentick/react hooks) to work with a local app without any network layer.
import { createApp } from "@agentick/core";
import { createLocalTransport } from "@agentick/core";
import { createClient } from "@agentick/client";
const app = createApp(MyAgent, { model });
const transport = createLocalTransport(app);
const client = createClient({ baseUrl: "local://", transport });
The transport is always "connected" — there's no network. send() delegates to app.send() and wraps each StreamEvent as a TransportEventData with the original event in the data field. Used by @agentick/tui for local agent mode.
See packages/shared/src/transport.ts for the ClientTransport interface.
License
MIT