Effect AI Prompt Construction
Master the Effect AI Prompt API for building type-safe conversations with language models.
Import Patterns
CRITICAL: Always use namespace imports:
import * as Prompt from 'effect/unstable/ai/Prompt';
import * as Response from 'effect/unstable/ai/Response';
import { pipe } from 'effect';
When to Use This Skill
- Constructing messages for language model requests
- Building multi-turn conversation history
- Adding system instructions to prompts
- Integrating tool calls and results into conversations
- Converting streaming responses to prompt history
- Managing file/image attachments in messages
- Implementing custom chat interfaces
Conceptual Model
-- Message hierarchy
type Message = SystemMessage | UserMessage | AssistantMessage | ToolMessage
type Part =
| TextPart
| ReasoningPart
| FilePart
| ToolCallPart
| ToolResultPart
| ToolApprovalRequestPart
| ToolApprovalResponsePart
-- Composition
Prompt.make :: RawInput → Prompt
Prompt.concat :: (Prompt, RawInput) → Prompt
Prompt.setSystem :: (Prompt, String) → Prompt
-- History transformation
fromResponseParts :: ReadonlyArray<Response.Part> → Prompt
The Effect v4 module also exports runtime schemas for every part and each role-specific part union: TextPart, ReasoningPart, FilePart, ToolCallPart, ToolResultPart, ToolApprovalRequestPart, ToolApprovalResponsePart, UserMessagePart, AssistantMessagePart, and ToolMessagePart. Use these schemas to decode unknown persisted or provider-adapter input instead of relying only on isPart.
Message Types
Each message has role and content. Content is an array of Part objects.
System Messages
import * as Prompt from 'effect/unstable/ai/Prompt';
// String content only
const system = Prompt.makeMessage('system', {
content: 'You are a helpful assistant specialized in mathematics.'
});
// Shorthand constructor
const systemShorthand = Prompt.systemMessage({
content: 'You are a helpful assistant specialized in mathematics.'
});
// System message with options
const systemWithOptions = Prompt.makeMessage('system', {
content: 'You are an expert coder.',
options: {
anthropic: { cache_control: { type: 'ephemeral' } }
}
});
User Messages
// Text-only user message
const userText = Prompt.makeMessage("user", {
content: [
Prompt.makePart("text", { text: "What is 2+2?" })
]
})
// Shorthand constructor
const userShorthand = Prompt.userMessage({
content: [
Prompt.makePart("text", { text: "What is 2+2?" })
]
})
// Multimodal user message (text + file)
const userMultimodal = Prompt.makeMessage("user", {
content: [
Prompt.makePart("text", { text: "What's in this image?" }),
Prompt.makePart("file", {
mediaType: "image/jpeg",
fileName: "photo.jpg",
data: new Uint8Array([...])
})
]
})
Assistant Messages
// Assistant response with text and tool call
const assistant = Prompt.makeMessage('assistant', {
content: [
Prompt.makePart('reasoning', {
text: 'I need to check the weather using the tool.'
}),
Prompt.makePart('tool-call', {
id: 'call_123',
name: 'get_weather',
params: { city: 'Paris' },
providerExecuted: false
}),
Prompt.makePart('tool-approval-request', {
approvalId: 'approval_123',
toolCallId: 'call_123'
}),
Prompt.makePart('text', {
text: 'The weather in Paris is sunny, 22°C.'
})
]
});
// Shorthand constructor
const assistantShorthand = Prompt.assistantMessage({
content: [
Prompt.makePart('text', {
text: 'The weather in Paris is sunny, 22°C.'
})
]
});
Tool Messages
// Tool execution results
const toolMessage = Prompt.makeMessage('tool', {
content: [
Prompt.makePart('tool-result', {
id: 'call_123',
name: 'get_weather',
isFailure: false,
result: { temperature: 22, condition: 'sunny' }
})
]
});
// Shorthand constructor
const toolShorthand = Prompt.toolMessage({
content: [
Prompt.makePart('tool-result', {
id: 'call_123',
name: 'get_weather',
isFailure: false,
result: { temperature: 22, condition: 'sunny' }
})
]
});
Part Types
Text Part
const textPart = Prompt.makePart('text', {
text: 'Hello, world!'
});
Reasoning Part
const reasoningPart = Prompt.makePart('reasoning', {
text: 'Let me think step by step...'
});
File Part
// From URL
const fileFromUrl = Prompt.makePart('file', {
mediaType: 'image/png',
fileName: 'screenshot.png',
data: new URL('https://example.com/image.png')
});
// From bytes
const fileFromBytes = Prompt.makePart('file', {
mediaType: 'application/pdf',
fileName: 'report.pdf',
data: new Uint8Array([1, 2, 3])
});
// From base64
const fileFromBase64 = Prompt.makePart('file', {
mediaType: 'image/jpeg',
data: 'data:image/jpeg;base64,/9j/4AAQ...'
});
For OpenAI file parts, strings are provider file IDs only when OpenAiLanguageModel.Config.fileIdPrefixes matches the string prefix (for example file-). Use URL or Uint8Array for inline content; the OpenAI adapter encodes bytes as base64 data.
Tool Call Part
const toolCall = Prompt.makePart('tool-call', {
id: 'call_abc123',
name: 'calculate',
params: { expression: '2 + 2' },
providerExecuted: false
});
Tool Result Part
const toolResult = Prompt.makePart('tool-result', {
id: 'call_abc123',
name: 'calculate',
isFailure: false,
result: 4
});
Tool Approval Parts
const approvalRequest = Prompt.makePart('tool-approval-request', {
approvalId: 'approval_abc123',
toolCallId: 'call_abc123'
});
const approvalResponse = Prompt.toolApprovalResponsePart({
approvalId: 'approval_abc123',
approved: true
});
const denialResponse = Prompt.toolApprovalResponsePart({
approvalId: 'approval_def456',
approved: false,
reason: 'Operation not allowed'
});
Tool approval requests live in assistant messages. Approval responses live in tool messages and are collected on the next model call.
Prompt Construction
From String
// Creates a user message with text part
const prompt = Prompt.make('Hello, how are you?');
From Encoded Messages
const prompt = Prompt.make([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: [{ type: 'text', text: 'Hi!' }] }
]);
Prompt.RawInput is exactly string | Iterable<Prompt.MessageEncoded> | Prompt.Prompt: strings become one user text message, iterables are decoded encoded messages, and existing prompts pass through unchanged.
From Existing Prompt
const copy = Prompt.make(existingPrompt);
Empty Prompt
const empty = Prompt.empty;
From Messages Array
// Using fromMessages constructor
const messages: ReadonlyArray<Prompt.Message> = [
Prompt.systemMessage({ content: 'You are an expert.' }),
Prompt.userMessage({
content: [Prompt.makePart('text', { text: 'Help me.' })]
})
];
const prompt = Prompt.fromMessages(messages);
// Alternative: Using make with messages array
const prompt2 = Prompt.make([
Prompt.makeMessage('system', { content: 'You are an expert.' }),
Prompt.makeMessage('user', {
content: [Prompt.makePart('text', { text: 'Help me.' })]
})
]);
Prompt Composition
Merge Prompts
import { pipe } from 'effect';
const systemPrompt = Prompt.make([
{
role: 'system',
content: 'You are a coding assistant.'
}
]);
const userPrompt = Prompt.make('Write a function');
// Data-last (pipeline)
const combined = pipe(systemPrompt, Prompt.concat(userPrompt));
// Data-first
const combined2 = Prompt.concat(systemPrompt, userPrompt);
System Message Manipulation
import { pipe } from 'effect';
const prompt = Prompt.make([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }
]);
// Replace system message
const replaced = pipe(
prompt,
Prompt.setSystem('You are an expert in TypeScript.')
);
// Prepend to system message
const prepended = pipe(prompt, Prompt.prependSystem('IMPORTANT: '));
// Result: "IMPORTANT: You are helpful."
// Append to system message
const appended = pipe(prompt, Prompt.appendSystem(' Be concise.'));
// Result: "You are helpful. Be concise."
History Management
Convert AI Response to Prompt
import * as Response from 'effect/unstable/ai/Response';
const responseParts: ReadonlyArray<Response.AnyPart> = [
Response.makePart('text-start', { id: 'text_1' }),
Response.makePart('text-delta', { id: 'text_1', delta: 'Hello' }),
Response.makePart('text-delta', { id: 'text_1', delta: '!' }),
Response.makePart('text-end', { id: 'text_1' }),
Response.makePart('tool-call', {
id: 'call_1',
name: 'get_time',
params: {},
providerExecuted: false
}),
Response.makePart('tool-approval-request', {
approvalId: 'approval_1',
toolCallId: 'call_1'
}),
Response.makePart('tool-result', {
id: 'call_1',
name: 'get_time',
isFailure: false,
result: '10:30 AM',
encodedResult: '10:30 AM',
providerExecuted: false,
preliminary: false
})
];
// Folds complete streaming text/reasoning and splits assistant/tool messages.
const historyPrompt = Prompt.fromResponseParts(responseParts);
Prompt.fromResponseParts folds streaming text/reasoning only when the matching start/delta/end parts are present in the same input, places tool calls and approval requests in assistant messages, and skips preliminary tool results. For final tool results it always uses encodedResult: framework-executed results (providerExecuted: false) become tool messages, while provider-executed results (providerExecuted: true) remain in the assistant message with that flag preserved.
This distinction matters for hosted tools such as provider web search or code execution. Moving their results into a tool message changes the conversation shape expected by the provider.
Effect-Returning Prompt/Message Helpers
Keep pure prompt helpers pure. But when prompt or message transformation depends on services, model metadata, truncation policy, storage, or other runtime state, wrap the transformation in Effect.fn(...) so orchestrators can yield* it directly.
import { Effect } from 'effect';
export const toModelMessagesEffect = Effect.fn('Prompt.toModelMessages')(
function* (messages: ReadonlyArray<AppMessage>) {
const policy = yield* MessagePolicy.Service;
return convertMessages(messages, policy);
}
);
This keeps prompt assembly inside the Effect graph instead of forcing Promise islands through session orchestration code.
Typical Chat Pattern
import { Effect } from 'effect';
import * as SubscriptionRef from 'effect/SubscriptionRef';
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
const chat = Effect.gen(function* () {
const history = yield* SubscriptionRef.make(Prompt.empty);
const generateText = Effect.fn('Chat.generateText')(function* (userInput: string) {
const currentHistory = yield* SubscriptionRef.get(history);
const prompt = pipe(currentHistory, Prompt.concat(userInput));
const response = yield* LanguageModel.generateText({ prompt });
// Update history with user input + response
const newHistory = pipe(
prompt,
Prompt.concat(Prompt.fromResponseParts(response.content))
);
yield* SubscriptionRef.set(history, newHistory);
return response;
});
return { generateText };
});
Usage with LanguageModel
Generate Text
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
import { Effect } from 'effect';
const program = Effect.gen(function* () {
const prompt = Prompt.make([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: [{ type: 'text', text: 'Explain Effect' }] }
]);
const response = yield* LanguageModel.generateText({ prompt });
return response.content;
});
Stream Text
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
import { Effect, Stream } from 'effect';
const program = Effect.gen(function* () {
const prompt = Prompt.make('Write a story');
yield* LanguageModel.streamText({ prompt }).pipe(
Stream.runForEach((part) =>
part.type === 'text-delta'
? Effect.sync(() => process.stdout.write(part.delta))
: Effect.void
)
);
});
Generate Object
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
import { Effect, Schema } from 'effect';
const Contact = Schema.Struct({
name: Schema.String,
email: Schema.String,
phone: Schema.optional(Schema.String)
});
const program = Effect.gen(function* () {
const prompt = Prompt.make('Extract: John Doe, john@example.com, 555-1234');
const response = yield* LanguageModel.generateObject({
prompt,
schema: Contact
});
return response.value;
});
Provider-Specific Options
OpenAI Responses explicit cache breakpoints (rc.112)
With @effect/ai-openai loaded, system-message and text-part options accept
openai.promptCacheBreakpoint. This requires GPT-5.6 or later; earlier models
may reject it. Provider config uses snake_case; Prompt metadata uses camelCase.
import { OpenAiLanguageModel } from '@effect/ai-openai';
import { Effect } from 'effect';
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
import * as Prompt from 'effect/unstable/ai/Prompt';
const program = LanguageModel.generateText({
prompt: Prompt.make([
Prompt.systemMessage({
content: 'Stable instructions',
options: { openai: { promptCacheBreakpoint: { mode: 'explicit' } } }
}),
Prompt.userMessage({
content: [Prompt.textPart({ text: 'Question for this turn' })]
})
])
}).pipe(Effect.provide(OpenAiLanguageModel.model('gpt-5.6', {
prompt_cache_key: 'assistant:v1',
prompt_cache_options: { mode: 'explicit', ttl: '30m' }
})));
A text part can carry the same breakpoint after reusable user context. The
adapter forwards it as prompt_cache_breakpoint on input text. Supply the
OpenAI client layer at the runtime boundary (see effect-ai-provider).
Other provider metadata
// Augment options interfaces via module augmentation
declare module 'effect/unstable/ai/Prompt' {
interface TextPartOptions {
readonly anthropic?: {
readonly cache_control?: {
readonly type: 'ephemeral';
};
};
}
}
// Use in parts
const cachedPart = Prompt.makePart('text', {
text: 'Large document...',
options: {
anthropic: { cache_control: { type: 'ephemeral' } }
}
});
// Use in messages
const cachedMessage = Prompt.makeMessage('system', {
content: 'You are an expert.',
options: {
anthropic: { cache_control: { type: 'ephemeral' } }
}
});
Serialization
Export/Import
import * as Schema from 'effect/Schema';
import { Effect } from 'effect';
// Prompt.Prompt is a Schema.Codec<Prompt, PromptEncoded>
const encode = Schema.encodeEffect(Prompt.Prompt);
const decode = Schema.decodeUnknownEffect(Prompt.Prompt);
const program = Effect.gen(function* () {
const prompt = Prompt.make('Hello');
// Export to structured data
const exported = yield* encode(prompt);
// Import from structured data
const imported = yield* decode(exported);
return imported;
});
JSON Serialization
Use Schema.fromJsonString with Prompt.Prompt to create a JSON codec:
import * as Schema from 'effect/Schema';
import { Effect } from 'effect';
const PromptJson = Schema.fromJsonString(Prompt.Prompt);
const encodeJson = Schema.encodeEffect(PromptJson);
const decodeJson = Schema.decodeUnknownEffect(PromptJson);
const program = Effect.gen(function* () {
const prompt = Prompt.make([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }
]);
// To JSON string
const json = yield* encodeJson(prompt);
yield* Effect.log(json); // string
// From JSON string
const restored = yield* decodeJson(json);
return restored;
});
Note: There is no Prompt.FromJson in v4. Use Schema.fromJsonString(Prompt.Prompt) instead.
Common Patterns
Multi-turn Conversation
const conversation = Prompt.make([
{ role: 'system', content: 'You are a math tutor.' },
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 equals 4.' }] },
{ role: 'user', content: [{ type: 'text', text: 'What about 3+3?' }] }
]);
Dynamic System Prompt
import { pipe } from 'effect';
function withSystemPrompt(content: string) {
return (prompt: Prompt.Prompt) => pipe(prompt, Prompt.setSystem(content));
}
const userPrompt = Prompt.make('Help me code');
const withContext = pipe(
userPrompt,
withSystemPrompt('You are an expert TypeScript developer.')
);
Tool Interaction History
const toolInteraction = Prompt.make([
{ role: 'user', content: [{ type: 'text', text: "What's the weather?" }] },
{
role: 'assistant',
content: [
{
type: 'tool-call',
id: '1',
name: 'weather',
params: {},
providerExecuted: false
}
]
},
{
role: 'tool',
content: [
{
type: 'tool-result',
id: '1',
name: 'weather',
isFailure: false,
result: 'Sunny, 22°C'
}
]
},
{
role: 'assistant',
content: [{ type: 'text', text: "It's sunny and 22°C." }]
}
]);
Type Guards
import * as Prompt from 'effect/unstable/ai/Prompt';
declare const value: unknown;
if (Prompt.isPrompt(value)) {
// value: Prompt.Prompt
value.content; // narrowed to Prompt.Prompt
}
if (Prompt.isMessage(value)) {
// value: Prompt.Message
value.role; // narrowed to Prompt.Message
}
if (Prompt.isPart(value)) {
// value: Prompt.Part
value.type; // narrowed to Prompt.Part
}
Anti-Patterns
// DON'T: Manually construct messages without constructors
const bad = {
role: 'user',
content: [{ type: 'text', text: 'Hello' }]
} as Prompt.UserMessage;
// DO: Use constructors
const good = Prompt.makeMessage('user', {
content: [Prompt.makePart('text', { text: 'Hello' })]
});
// DO: Use shorthand constructors
const better = Prompt.userMessage({
content: [Prompt.makePart('text', { text: 'Hello' })]
});
// DON'T: Mutate prompt content
const prompt = Prompt.make('Hi');
prompt.content.push(someMessage); // Error: readonly
// DO: Use merge for composition
const extended = Prompt.concat(prompt, 'Additional message');
// DON'T: Manually filter response parts
const filtered = responseParts.filter((p) => p.type === 'text');
// DO: Use fromResponseParts (handles streaming deltas, tool results, etc.)
const historyPrompt = Prompt.fromResponseParts(responseParts);
Decoding Unknown Parts
import * as Schema from 'effect/Schema';
const decodeAssistantPart = Schema.decodeUnknownEffect(
Prompt.AssistantMessagePart
);
const decoded = yield* decodeAssistantPart(unknownPart);
Use Prompt.Part for the unrestricted part union or a role-specific schema when the destination message role is already known.
Related Skills
- effect-ai-language-model - Using prompts with LanguageModel service
- effect-ai-streaming - Converting streaming responses to prompt history
- effect-ai-tool - Integrating tool call/result messages
Quality Checklist
- Messages use
Prompt.makeMessageor shorthand constructors - Parts use
Prompt.makePartconstructors - System messages managed with
setSystem/prependSystem/appendSystem - History updates use
Prompt.fromResponseParts - Prompt composition uses
Prompt.concat(not mutation) - Namespace imports for all Effect AI modules