Prompts
prompt(), createPrompts(), Prompt, and prompt resolution.
import { prompt, createPrompts } from '@crux/core'prompt() creates a frozen, SDK-agnostic prompt definition. It is the contract adapters execute and the object Crux inspects, tests, caches, and instruments.
prompt(config)
const summarize = prompt({
id: 'summarize',
input: z.object({ text: z.string() }),
output: z.object({ summary: z.string() }),
system: 'Summarize the text in one sentence.',
prompt: ({ input }) => input.text,
})Config
| Field | Type | Description |
|---|---|---|
id | string? | Stable identifier for registry lookup, logs, devtools, evals, and cache keys. |
description | string? | Human-readable description. |
tags | readonly string[]? | Tags for filtering, discovery, and eval grouping. |
use | readonly ContextEntry[]? | Composition entries. Accepts context(), when(), match(), skills, memory, blackboards, retrievers, retrieval pipelines, grounding, custom injectable() entries, and falsy values. |
input | ZodType? | Prompt-owned input schema. Merged with context/injectable input schemas. |
output | ZodType? | Structured output schema. Omit for text generation. |
system | string | ({ input }) => string | Promise<string> | Role, policy, and high-level instructions. Rendered first and never dropped by token budgeting. |
prompt | string | ({ input }) => string | Single-turn user/task message. |
messages | ({ input }) => Message[] | Message-list mode for chat transcripts, few-shot examples, and multi-turn prompts. |
settings | GenerationSettings? | SDK-agnostic defaults such as temperature, maxTokens, topP, topK, and provider pass-through settings. |
adapt | AdapterMap? | Provider/model-specific system, prompt, and settings overrides. |
hooks | PromptHooks? | Per-prompt lifecycle hooks. |
tools | ToolSet? | Prompt-local tools. Injected tools and context tools are merged with these at resolution time. |
toolMiddleware | ToolMiddleware | readonly ToolMiddleware[]? | Server-side wrappers for prompt tools, including audit hooks and human approval. |
toolChoice | ToolChoice? | Tool selection strategy for adapters that support it. |
stopWhen | StopCondition? | Stop condition for multi-step tool use. |
tests | EvalCase[]? | Inline eval cases discovered by the eval runner. |
constraints | ConstraintDef[]? | Semantic constraints evaluated around generation. |
guardrails | GuardrailDef[]? | Pre/post generation guardrails. |
cache | PromptCacheOptions? | Prompt cache hints consumed by plugins such as semantic response caching. |
Return Value
prompt() returns a frozen Prompt.
| Property | Description |
|---|---|
_tag | Runtime discriminant: 'Prompt'. |
id | Prompt identifier. |
description | Description string. |
tags | Tag array. |
contexts | The original use tuple. |
inputSchema | Runtime merged input schema, if any. |
outputSchema | Output schema, if configured. |
hasOutput | Whether structured output is configured. |
config | Readonly original config. |
| Method | Description |
|---|---|
.resolve(options) | Validates input and returns the composed ResolvedPrompt without calling a model. |
.inspect(options) | Returns token/count/source breakdowns for debugging composition and token budgets. |
Tool Model Output
Text-mode prompts can use tools. A tool has two outputs:
const lookupCustomer = tool({
description: 'Lookup a customer',
parameters: z.object({ customerId: z.string() }),
execute: async ({ customerId }) => {
return await loadCustomerRecord(customerId)
},
toModelOutput: ({ output }) => ({
type: 'json',
value: {
id: output.id,
plan: output.plan,
supportTier: output.supportTier,
},
}),
})execute() returns the raw value for your application. toModelOutput() returns the value sent back to the model for the next tool-use step.
type ToolModelOutput =
| { type: 'text'; value: string }
| { type: 'json'; value: JsonValue }
| { type: 'execution-denied'; reason?: string }
| { type: 'error-text'; value: string }
| { type: 'error-json'; value: JsonValue }
| { type: 'content'; value: readonly ToolContentPart[] }The shape is provider-neutral and compatible with the AI SDK's model-message format. @crux/ai passes it through directly. Native adapters use the same metadata: Google maps supported media to function-response parts, Anthropic maps text/images/PDFs to native tool_result content blocks, and OpenAI Chat Completions receives deterministic text references for non-text parts because that API only accepts text tool results.
Composition With use
use is intentionally broad. The prompt resolver asks each entry what it contributes.
const assistant = prompt({
id: 'assistant',
use: [
brandContext,
userMemory,
docsGrounding,
threadBlackboard,
process.env.NODE_ENV === 'development' && debugContext,
],
input: z.object({
userId: z.string(),
question: z.string(),
}),
system: 'Answer carefully.',
prompt: ({ input }) => input.question,
})Every entry below is injectable during prompt resolution. Dedicated primitives have their own API because they need richer lifecycle behavior than a plain context; custom cases can use injectable().
Common entry behavior:
| Entry | What it contributes |
|---|---|
context() | System text, input fields, tools, constraints, and guardrails. |
when() | Includes a context only when its predicate passes. |
match() | Selects one branch of contexts by runtime discriminator. |
memory() | Memory context, memory tools, capture/flush lifecycle. |
blackboard() | Shared state context plus focused read/write tools. |
retriever() / retrievalPipeline() | Retrieval context, search/source tools, and trace metadata according to injection options. |
grounding() | Evidence injection, citation metadata, constraints, and optional tools. |
skill() | A skill index plus LoadSkill and LoadReference tools. Loaded skill instructions are re-injected at system-prompt level. |
injectable() | Custom context, tools, constraints, guardrails, and metadata. |
contributor() | Everything injectable() does, plus a when gate with exclusion reporting, nested use entries, and pipeline re-entry with any entry kind. |
false, null, undefined | Ignored. Useful for static flags. |
Manual .asContext() and .asTools() helpers remain useful when integrating with a framework that does not run Crux prompt resolution directly. For normal prompts, prefer putting the primitive in use.
Custom Injectable Entries
Use injectable() when you are building your own primitive that should compose through use.
import { context, injectable, prompt } from '@crux/core'
import { z } from 'zod'
const accountPlan = injectable({
id: 'account-plan',
input: z.object({ accountId: z.string() }),
async inject({ input }) {
const plan = await loadAccountPlan(input.accountId)
return {
contexts: [
context({
id: 'account-plan-context',
system: `Current account plan: ${plan.name}`,
}),
],
metadata: {
accountPlan: plan.name,
},
}
},
})
const support = prompt({
id: 'support',
use: [accountPlan],
input: z.object({
accountId: z.string(),
question: z.string(),
}),
system: 'Answer using the current account plan.',
prompt: ({ input }) => input.question,
})Reach for dedicated primitives first: skills, memory, blackboards, retrieval and grounding. Use injectable() for project-specific composition that does not belong in one of those categories.
Custom Contributor Entries
contributor() is the next step up from injectable(): a first-class use entry that can gate itself, bundle nested entries, and contribute to every prompt channel.
import { contributor, prompt } from '@crux/core'
import { z } from 'zod'
const supportTools = contributor({
id: 'support-tools',
input: z.object({ plan: z.string() }),
// Excluded with a recorded reason when false — visible in .inspect()
// and devtools. contribute() is never called for excluded entries.
when: (input) => input.plan !== 'free',
// Resolved BEFORE this contributor's own contribution.
use: [docsRetriever.asContext({ topK: 4 })],
contribute: async ({ input }) => ({
tools: await loadSupportTools(input.plan),
metadata: { supportTier: input.plan },
}),
})| Option | Type | Description |
|---|---|---|
id | string | Required. Appears in observability artifacts (contributor:<id>), exclusion records, and tool-collision errors. |
family | string? | Observability grouping label. Defaults to injectable. |
input | ZodObject? | Fields merged into the prompt's input schema as required keys; typed in contribute(). |
when | (input) => boolean | Synchronous gate. Excluded entries record { source: 'contributor:<id>', reason: 'when() predicate returned false' }. |
use | readonly ContextEntry[]? | Nested entries resolved before the contribution — any entry kind, including other contributors. |
contribute | (args) => ContributorContribution | Async-capable. Returned contexts/use re-enter the pipeline; tools merge with collision detection; constraints, guardrails, and metadata land on the resolved prompt. |
Entries created by contributor() resolve through the same compiled prompt pass as contexts, memory, skills, and blackboards.
Input Inference
The final input type is:
Prompt input = prompt.input & all active context input schemasPlain contexts contribute required fields. Conditional contexts contribute optional fields because Crux cannot know at compile time which runtime branch will be active.
import { , , } from '@crux/core'
import { } from 'zod'
const = ({
: 'locale',
: .({ : .() }),
: ({ }) => `Locale: ${.}`,
})
const = ({
: 'brand',
: .({ : .().() }),
: ({ }) => `Brand voice: ${. ?? 'default'}`,
})
const = ({
: 'reply',
: [
,
(({ }) => (), ),
],
: .({ : .() }),
: 'Reply to the user.',
: ({ }) => .,
})In this example, question and locale are required. brandVoice is optional.
System Composition Order
When resolving system mode, Crux composes text in this order:
- The prompt's own
systemtext. - Active
useentries in array order. - Nested entries before the context that owns them.
priority does not reorder normal output. It controls token-budget dropping: low-priority contexts are dropped first, and the prompt's own system text is always kept.
const resolved = await assistant.resolve({
input: { userId: 'user_123', question: 'How do I configure SSO?' },
tokenBudget: 4000,
})
resolved.systemWhen observability is enabled, prompt resolution emits composition artifacts for devtools. Resolved contexts use context.contribution artifacts (active, checked-not-included, or dropped-budget state plus source, tokens, priority, cache status, and reason/branch when relevant). Calls with tokenBudget also emit a prompt.budget artifact showing usedTokens, totalTokens, and dropped contributions.
system + prompt Mode
Use this mode for most single-turn calls.
const rewrite = prompt({
input: z.object({ text: z.string() }),
system: 'Rewrite without changing meaning.',
prompt: ({ input }) => input.text,
})Adapters receive a resolved system string and a user prompt string.
messages Mode
Use messages when you need a full message list.
const reply = prompt({
input: z.object({ userMessage: z.string() }),
system: 'You are concise.',
messages: ({ input }) => [
{ role: 'user', content: 'Answer in one sentence.' },
{ role: 'assistant', content: 'Understood.' },
{ role: 'user', content: input.userMessage },
],
})Context and injected system text is still resolved. Adapters map the resolved prompt into their SDK-specific message format.
Structured vs Text Output
output controls the adapter execution path.
const textPrompt = prompt({
system: 'Write a title.',
prompt: 'A guide to vector search',
})
const objectPrompt = prompt({
output: z.object({ title: z.string() }),
system: 'Write a title.',
prompt: 'A guide to vector search',
})| Prompt shape | Adapter behavior |
|---|---|
No output | Text generation. Results expose text in adapter-specific shape. |
With output | Structured generation. Results expose validated object data in adapter-specific shape. |
Provider Adaptation
Use adapt when the same prompt needs small provider-specific changes.
const extract = prompt({
id: 'extract',
output: z.object({ entities: z.array(z.string()) }),
system: 'Extract named entities.',
prompt: ({ input }) => input.text,
adapt: {
openai: {
settings: { temperature: 0 },
},
anthropic: {
appendSystem: '\nReturn concise JSON only.',
},
'*': {
settings: { maxTokens: 800 },
},
},
})Resolution checks exact provider, slash-prefixed model providers such as openai/gpt-4o, then '*'.
.resolve(options)
.resolve() returns SDK-agnostic prepared data. It is useful for debugging, custom adapters, and framework bridges.
const resolved = await support.resolve({
input: { userId: 'user_123', question: 'How do I configure SSO?' },
provider: 'openai',
modelId: 'gpt-4o',
})
resolved.system
resolved.prompt
resolved.messages
resolved.tools
resolved.constraints
resolved.guardrails
resolved.metadata
resolved.settings.resolve() does not call a model.
.inspect(options)
.inspect() runs resolution and returns debugging metadata.
const inspection = await support.inspect({
input: { userId: 'user_123', question: 'How do I configure SSO?' },
tokenBudget: 4000,
})
inspection.system.parts
inspection.droppedContexts
inspection.excludedContexts
inspection.totalTokensUse this when a prompt has many use entries and you need to understand what was included, excluded, or dropped.
compilePrompt(config, { ports? })
Compile a prompt config once: config validation, input-schema merge/conflict detection, and resolver port binding. Each resolve() call runs one pipeline pass and returns a Resolution with SDK-ready args plus an inspection view derived from the same pass.
import { compilePrompt, recordingObservability, fixedClock, inMemoryContextCache } from '@crux/core'
const observability = recordingObservability()
const clock = fixedClock(1_000)
const compiled = compilePrompt(config, {
ports: {
observability,
clock,
cache: inMemoryContextCache(clock),
},
})
const pass = await compiled.resolve({ input })
const resolved = pass.args
const inspection = pass.inspect()
observability.contributionPreviews('checked-not-included') // exclusions, no transport neededPorts: observability, skills, cache, clock, policy, diagnostics, instrumentation. Omitted ports use the production runtime adapters, so compilePrompt(config) uses the same ambient pipeline as prompt(config). In-memory fakes for every port (recordingObservability, inMemorySkillSource, inMemoryContextCache, fixedClock, collectingDiagnostics, staticPolicy, recordingInstrumentation) ship from @crux/core.
compiled.inspect() runs the same pipeline in quiet inspection mode. pass.inspect() is free and never re-runs the pipeline, which means lifecycle hooks and debuggers observe facts from the exact resolution sent to the adapter.
For adapter and primitive authors, the lowered contributor contract types behind the pipeline are also exported: LoweredContributor, Contribution, GateResult, MergedResolution, and related types. The lowering, driver, and schema-collection functions are internal to the compiled prompt boundary. Application code never needs these.
createPrompts(tree)
Organizes prompts into a deeply frozen tree with type inference at every level.
const prompts = createPrompts({
editor: {
rewrite,
classify,
},
support: {
answerSupport,
},
})
prompts.editor.rewrite
prompts.support.answerSupport
prompts._all_all is a non-enumerable flat list used by registries and eval runners.