diff --git a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
index a7673704..5f499882 100644
--- a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
+++ b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
@@ -4,7 +4,7 @@
net9.0
enable
enable
- 2.0.0-alpha.7
+ 2.0.0-alpha.8
Prompty.Anthropic
Microsoft
Anthropic provider for Prompty — executor and processor for Claude models via the Anthropic Messages API.
diff --git a/runtime/csharp/Prompty.Core/Prompty.Core.csproj b/runtime/csharp/Prompty.Core/Prompty.Core.csproj
index f2ec5b48..2db681e7 100644
--- a/runtime/csharp/Prompty.Core/Prompty.Core.csproj
+++ b/runtime/csharp/Prompty.Core/Prompty.Core.csproj
@@ -4,7 +4,7 @@
net9.0
enable
enable
- 2.0.0-alpha.7
+ 2.0.0-alpha.8
Prompty.Core
Microsoft
Prompty is an asset class and format for LLM prompts. Core library with loader, pipeline, renderers, parsers, and tracing.
diff --git a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
index da398bad..bdd3f6ab 100644
--- a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
+++ b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
@@ -6,7 +6,7 @@
enable
OPENAI001;CS1591
true
- 2.0.0-alpha.7
+ 2.0.0-alpha.8
Prompty.Foundry
Microsoft
Microsoft Foundry (Azure OpenAI) provider for Prompty — executor and processor for Azure-hosted models.
diff --git a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
index ce7ca434..f625fd70 100644
--- a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
+++ b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
@@ -6,7 +6,7 @@
enable
OPENAI001;CS1591
true
- 2.0.0-alpha.7
+ 2.0.0-alpha.8
Prompty.OpenAI
Microsoft
OpenAI provider for Prompty — executor and processor for OpenAI chat, embedding, image, and agent APIs.
diff --git a/runtime/python/prompty/prompty/_version.py b/runtime/python/prompty/prompty/_version.py
index bb6b6459..747aa68b 100644
--- a/runtime/python/prompty/prompty/_version.py
+++ b/runtime/python/prompty/prompty/_version.py
@@ -1 +1 @@
-VERSION = "2.0.0a7"
+VERSION = "2.0.0a8"
diff --git a/runtime/python/prompty/prompty/parsers/prompty.py b/runtime/python/prompty/prompty/parsers/prompty.py
index 384f7c3d..afc97f11 100644
--- a/runtime/python/prompty/prompty/parsers/prompty.py
+++ b/runtime/python/prompty/prompty/parsers/prompty.py
@@ -173,7 +173,8 @@ def _build_message(
# Validate nonce in strict mode
if nonce is not None:
msg_nonce = attrs.pop("nonce", None)
- if msg_nonce != nonce:
+ # Compare as strings — _parse_attrs may coerce all-digit hex nonces to int
+ if str(msg_nonce) != nonce:
raise ValueError(
"Nonce mismatch — possible prompt injection detected "
"(strict mode is enabled). A template variable may be "
diff --git a/runtime/rust/prompty/src/parsers/prompty.rs b/runtime/rust/prompty/src/parsers/prompty.rs
index 5012a6b1..5b6a406d 100644
--- a/runtime/rust/prompty/src/parsers/prompty.rs
+++ b/runtime/rust/prompty/src/parsers/prompty.rs
@@ -145,7 +145,15 @@ fn build_message(
) -> Result {
// Validate nonce in strict mode
if let Some(expected) = expected_nonce {
- let msg_nonce = attrs.get("nonce").and_then(|v| v.as_str()).unwrap_or("");
+ // Compare as string — parse_attrs may coerce all-digit hex nonces to Number
+ let msg_nonce = attrs
+ .get("nonce")
+ .map(|v| match v {
+ serde_json::Value::String(s) => s.clone(),
+ serde_json::Value::Number(n) => n.to_string(),
+ _ => String::new(),
+ })
+ .unwrap_or_default();
if msg_nonce != expected {
return Err(InvokerError::Parse(
"Nonce mismatch — possible prompt injection detected \
diff --git a/runtime/typescript/packages/anthropic/package.json b/runtime/typescript/packages/anthropic/package.json
index b96d32dc..4645640b 100644
--- a/runtime/typescript/packages/anthropic/package.json
+++ b/runtime/typescript/packages/anthropic/package.json
@@ -1,6 +1,6 @@
{
"name": "@prompty/anthropic",
- "version": "2.0.0-alpha.7",
+ "version": "2.0.0-alpha.8",
"description": "Anthropic provider for Prompty — executor and processor for Anthropic Messages API",
"type": "module",
"main": "./dist/index.cjs",
@@ -46,7 +46,7 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
+ "@prompty/core": "^2.0.0-alpha.8",
"@anthropic-ai/sdk": ">=0.39.0"
},
"peerDependenciesMeta": {
diff --git a/runtime/typescript/packages/core/package.json b/runtime/typescript/packages/core/package.json
index a2156358..346902f9 100644
--- a/runtime/typescript/packages/core/package.json
+++ b/runtime/typescript/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@prompty/core",
- "version": "2.0.0-alpha.7",
+ "version": "2.0.0-alpha.8",
"description": "Prompty core runtime — load, render, parse, and trace .prompty files",
"type": "module",
"main": "./dist/index.cjs",
diff --git a/runtime/typescript/packages/core/src/parsers/prompty.ts b/runtime/typescript/packages/core/src/parsers/prompty.ts
index 97c81b2e..9784130c 100644
--- a/runtime/typescript/packages/core/src/parsers/prompty.ts
+++ b/runtime/typescript/packages/core/src/parsers/prompty.ts
@@ -123,9 +123,10 @@ export class PromptyChatParser implements Parser {
// Validate nonce in strict mode
if (nonce !== undefined) {
- const msgNonce = attrs.nonce as string | undefined;
+ const msgNonce = attrs.nonce;
delete attrs.nonce;
- if (msgNonce !== nonce) {
+ // Compare as strings — parseAttrs may coerce all-digit hex nonces to numbers
+ if (String(msgNonce ?? "") !== nonce) {
throw new Error(
"Nonce mismatch — possible prompt injection detected " +
"(strict mode is enabled). A template variable may be " +
diff --git a/runtime/typescript/packages/foundry/package.json b/runtime/typescript/packages/foundry/package.json
index 78ee12b8..631c80f8 100644
--- a/runtime/typescript/packages/foundry/package.json
+++ b/runtime/typescript/packages/foundry/package.json
@@ -1,6 +1,6 @@
{
"name": "@prompty/foundry",
- "version": "2.0.0-alpha.7",
+ "version": "2.0.0-alpha.8",
"description": "Microsoft Foundry provider for Prompty — executor and processor for Azure AI Foundry",
"type": "module",
"main": "./dist/index.cjs",
@@ -46,8 +46,8 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
- "@prompty/openai": "^2.0.0-alpha.7"
+ "@prompty/core": "^2.0.0-alpha.8",
+ "@prompty/openai": "^2.0.0-alpha.8"
},
"dependencies": {
"@azure/ai-projects": "^2.0.1",
@@ -55,8 +55,8 @@
"openai": "^4.80.0"
},
"devDependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
- "@prompty/openai": "^2.0.0-alpha.7",
+ "@prompty/core": "^2.0.0-alpha.8",
+ "@prompty/openai": "^2.0.0-alpha.8",
"@types/node": "^20.11.0",
"dotenv": "^16.4.0",
"tsup": "^8.4.0",
diff --git a/runtime/typescript/packages/openai/package.json b/runtime/typescript/packages/openai/package.json
index b34bc831..ac93837e 100644
--- a/runtime/typescript/packages/openai/package.json
+++ b/runtime/typescript/packages/openai/package.json
@@ -1,6 +1,6 @@
{
"name": "@prompty/openai",
- "version": "2.0.0-alpha.7",
+ "version": "2.0.0-alpha.8",
"description": "OpenAI provider for Prompty — executor and processor for OpenAI APIs",
"type": "module",
"main": "./dist/index.cjs",
@@ -45,13 +45,13 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "@prompty/core": "^2.0.0-alpha.7"
+ "@prompty/core": "^2.0.0-alpha.8"
},
"dependencies": {
"openai": "^4.80.0"
},
"devDependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
+ "@prompty/core": "^2.0.0-alpha.8",
"@types/node": "^20.11.0",
"dotenv": "^16.4.0",
"tsup": "^8.4.0",
diff --git a/vscode/prompty/packages/core/package-lock.json b/vscode/prompty/packages/core/package-lock.json
index c5944b2d..9b85b93e 100644
--- a/vscode/prompty/packages/core/package-lock.json
+++ b/vscode/prompty/packages/core/package-lock.json
@@ -12,10 +12,10 @@
"@anthropic-ai/sdk": "^0.39.0",
"@azure/ai-projects": "^2.0.1",
"@azure/identity": "^4.6.0",
- "@prompty/anthropic": "^2.0.0-alpha.7",
- "@prompty/core": "^2.0.0-alpha.7",
- "@prompty/foundry": "^2.0.0-alpha.7",
- "@prompty/openai": "^2.0.0-alpha.7",
+ "@prompty/anthropic": "^2.0.0-alpha.8",
+ "@prompty/core": "^2.0.0-alpha.8",
+ "@prompty/foundry": "^2.0.0-alpha.8",
+ "@prompty/openai": "^2.0.0-alpha.8",
"glob": "^11.1.0",
"openai": "^4.80.0",
"vscode-languageclient": "^9.0.1"
@@ -408,8 +408,8 @@
}
},
"node_modules/@prompty/anthropic": {
- "version": "2.0.0-alpha.7",
- "resolved": "https://registry.npmjs.org/@prompty/anthropic/-/anthropic-2.0.0-alpha.7.tgz",
+ "version": "2.0.0-alpha.8",
+ "resolved": "https://registry.npmjs.org/@prompty/anthropic/-/anthropic-2.0.0-alpha.8.tgz",
"integrity": "sha512-vz8Lc7Hp4upvFmMRV9tNsMOhTqvqQjSo0HrdJKi/zWa6XKfeMeewIygN8z56XaPllOeVJPACqzrKB9TUa5GCbA==",
"license": "MIT",
"engines": {
@@ -417,7 +417,7 @@
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.39.0",
- "@prompty/core": "^2.0.0-alpha.7"
+ "@prompty/core": "^2.0.0-alpha.8"
},
"peerDependenciesMeta": {
"@anthropic-ai/sdk": {
@@ -426,8 +426,8 @@
}
},
"node_modules/@prompty/core": {
- "version": "2.0.0-alpha.7",
- "resolved": "https://registry.npmjs.org/@prompty/core/-/core-2.0.0-alpha.7.tgz",
+ "version": "2.0.0-alpha.8",
+ "resolved": "https://registry.npmjs.org/@prompty/core/-/core-2.0.0-alpha.8.tgz",
"integrity": "sha512-vHQCzmHv3m3bx/aZRXMYg67idit1xBEPNg6AKKR/0GeUj8Z5l2LAXLEoqK7xo46OBs07RdNeHXcwtWd1nZhZ8g==",
"license": "MIT",
"peer": true,
@@ -450,8 +450,8 @@
}
},
"node_modules/@prompty/foundry": {
- "version": "2.0.0-alpha.7",
- "resolved": "https://registry.npmjs.org/@prompty/foundry/-/foundry-2.0.0-alpha.7.tgz",
+ "version": "2.0.0-alpha.8",
+ "resolved": "https://registry.npmjs.org/@prompty/foundry/-/foundry-2.0.0-alpha.8.tgz",
"integrity": "sha512-7AiZjzFiKOkY4OQ4egOT4De+W6kpk8MwtcSAuugk82BPq+2XbCZv3SsOhVKmWhoHG16qQ/j2FpPLdbtAtM/Atw==",
"license": "MIT",
"dependencies": {
@@ -463,13 +463,13 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
- "@prompty/openai": "^2.0.0-alpha.7"
+ "@prompty/core": "^2.0.0-alpha.8",
+ "@prompty/openai": "^2.0.0-alpha.8"
}
},
"node_modules/@prompty/openai": {
- "version": "2.0.0-alpha.7",
- "resolved": "https://registry.npmjs.org/@prompty/openai/-/openai-2.0.0-alpha.7.tgz",
+ "version": "2.0.0-alpha.8",
+ "resolved": "https://registry.npmjs.org/@prompty/openai/-/openai-2.0.0-alpha.8.tgz",
"integrity": "sha512-kJsHlYz/7J++79sw++pymwZW6YYjW78ToxtgnkuS9FDqB3VUHOMEGKjQJzlfj8McmDywmx/zpqHG+PHPr+ZpjA==",
"license": "MIT",
"peer": true,
@@ -480,7 +480,7 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "@prompty/core": "^2.0.0-alpha.7"
+ "@prompty/core": "^2.0.0-alpha.8"
}
},
"node_modules/@types/node": {
diff --git a/vscode/prompty/packages/core/package.json b/vscode/prompty/packages/core/package.json
index a8f2e88f..89b27a06 100644
--- a/vscode/prompty/packages/core/package.json
+++ b/vscode/prompty/packages/core/package.json
@@ -16,10 +16,10 @@
"@anthropic-ai/sdk": "^0.39.0",
"@azure/ai-projects": "^2.0.1",
"@azure/identity": "^4.6.0",
- "@prompty/anthropic": "^2.0.0-alpha.7",
- "@prompty/core": "^2.0.0-alpha.7",
- "@prompty/foundry": "^2.0.0-alpha.7",
- "@prompty/openai": "^2.0.0-alpha.7",
+ "@prompty/anthropic": "^2.0.0-alpha.8",
+ "@prompty/core": "^2.0.0-alpha.8",
+ "@prompty/foundry": "^2.0.0-alpha.8",
+ "@prompty/openai": "^2.0.0-alpha.8",
"glob": "^11.1.0",
"openai": "^4.80.0",
"vscode-languageclient": "^9.0.1"
diff --git a/vscode/prompty/packages/server/package-lock.json b/vscode/prompty/packages/server/package-lock.json
index ca0de2d6..0b28b453 100644
--- a/vscode/prompty/packages/server/package-lock.json
+++ b/vscode/prompty/packages/server/package-lock.json
@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
+ "@prompty/core": "^2.0.0-alpha.8",
"jsonc-parser": "^3.3.1",
"vscode-json-languageservice": "^5.7.2",
"vscode-languageserver": "^9.0.1",
@@ -126,8 +126,8 @@
}
},
"node_modules/@prompty/core": {
- "version": "2.0.0-alpha.7",
- "resolved": "https://registry.npmjs.org/@prompty/core/-/core-2.0.0-alpha.7.tgz",
+ "version": "2.0.0-alpha.8",
+ "resolved": "https://registry.npmjs.org/@prompty/core/-/core-2.0.0-alpha.8.tgz",
"integrity": "sha512-vHQCzmHv3m3bx/aZRXMYg67idit1xBEPNg6AKKR/0GeUj8Z5l2LAXLEoqK7xo46OBs07RdNeHXcwtWd1nZhZ8g==",
"license": "MIT",
"dependencies": {
diff --git a/vscode/prompty/packages/server/package.json b/vscode/prompty/packages/server/package.json
index 48003a68..1d596e93 100644
--- a/vscode/prompty/packages/server/package.json
+++ b/vscode/prompty/packages/server/package.json
@@ -8,7 +8,7 @@
"node": "*"
},
"dependencies": {
- "@prompty/core": "^2.0.0-alpha.7",
+ "@prompty/core": "^2.0.0-alpha.8",
"jsonc-parser": "^3.3.1",
"vscode-json-languageservice": "^5.7.2",
"vscode-languageserver": "^9.0.1",
@@ -33,4 +33,4 @@
"yaml": "2.8.3"
}
}
-}
\ No newline at end of file
+}
diff --git a/web/src/content/docs/core-concepts/conversation-history.mdx b/web/src/content/docs/core-concepts/conversation-history.mdx
new file mode 100644
index 00000000..6f3d3598
--- /dev/null
+++ b/web/src/content/docs/core-concepts/conversation-history.mdx
@@ -0,0 +1,448 @@
+---
+title: Conversation History (Threads)
+description: >
+ How to use thread inputs to give your prompts multi-turn conversation
+ context — declaration, data format, template placement, and how the
+ pipeline keeps user input safe with nonce-based expansion.
+sidebar:
+ order: 3
+---
+
+import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
+
+## Overview
+
+Most LLM applications need **multi-turn conversation**. The model needs to
+see prior messages — what the user asked and what it answered — to maintain
+coherent context. In Prompty, conversation history is handled through
+**thread inputs**: a special input kind that tells the pipeline to splice a
+list of messages into the prompt at exactly the right position.
+
+```mermaid
+flowchart LR
+ S["system:\nYou are helpful."]
+ T["🧵 Thread Messages\n(prior turns)"]
+ U["user:\nNew question"]
+
+ S --> T --> U
+
+ style S fill:#dbeafe,stroke:#3b82f6,color:#1e293b
+ style T fill:#fef3c7,stroke:#f59e0b,color:#78350f
+ style U fill:#d1fae5,stroke:#10b981,color:#065f46
+```
+
+The result is a flat message array — system prompt, then prior conversation,
+then the new user message — ready for the LLM.
+
+---
+
+## Declaring a Thread Input
+
+Add an input with `kind: thread` to your `.prompty` file's `inputs`:
+
+```yaml {6-8}
+---
+name: chat-assistant
+model:
+ id: gpt-4o-mini
+ provider: openai
+ connection:
+ kind: key
+ apiKey: ${env:OPENAI_API_KEY}
+inputs:
+ - name: question
+ kind: string
+ default: Hello!
+ - name: conversation
+ kind: thread
+---
+```
+
+Two things make a thread input different from a regular string or object input:
+
+1. **`kind: thread`** — signals the pipeline to use special handling (nonce-based
+ expansion) instead of simple template interpolation.
+2. **The value is a list of messages** — not a scalar. Each message has a `role`
+ and `content`.
+
+---
+
+## Placing the Thread in Your Template
+
+In the markdown body, place `{{conversation}}` (or whatever you named your
+thread input) where the prior messages should appear. The most common pattern
+puts it between the system prompt and the new user message:
+
+```text
+system:
+You are a friendly, helpful assistant.
+
+{{conversation}}
+user:
+{{question}}
+```
+
+This produces a message array like:
+
+| # | Role | Content |
+|---|------|---------|
+| 1 | `system` | You are a friendly, helpful assistant. |
+| 2 | `user` | *(first turn — from thread)* |
+| 3 | `assistant` | *(first response — from thread)* |
+| 4 | `user` | *(second turn — from thread)* |
+| 5 | `assistant` | *(second response — from thread)* |
+| … | … | … |
+| N | `user` | *(current question)* |
+
+
+
+---
+
+## Passing Thread Data
+
+Thread data is a **list of message objects**. Each message needs a `role`
+(typically `"user"` or `"assistant"`) and `content` (a string or structured
+content array).
+
+
+
+ ```python
+ import prompty
+
+ history = []
+
+ while True:
+ question = input("You: ")
+ if question.lower() in ("quit", "exit"):
+ break
+
+ result = prompty.invoke(
+ "assistant.prompty",
+ inputs={
+ "question": question,
+ "conversation": history,
+ },
+ )
+ print(f"Assistant: {result}\n")
+
+ # Append this exchange to history for the next turn
+ history.append({"role": "user", "content": question})
+ history.append({"role": "assistant", "content": result})
+ ```
+
+
+ ```typescript
+ import { invoke } from "@prompty/core";
+ import "@prompty/openai";
+
+ const history: { role: string; content: string }[] = [];
+
+ // ... in your chat loop:
+ const result = await invoke("assistant.prompty", {
+ question: userMessage,
+ conversation: history,
+ });
+
+ history.push({ role: "user", content: userMessage });
+ history.push({ role: "assistant", content: String(result) });
+ ```
+
+
+ ```csharp
+ using Prompty.Core;
+
+ var history = new List>();
+
+ // ... in your chat loop:
+ var result = await Pipeline.InvokeAsync("assistant.prompty", new()
+ {
+ ["question"] = question,
+ ["conversation"] = history,
+ });
+
+ history.Add(new() { ["role"] = "user", ["content"] = question });
+ history.Add(new() { ["role"] = "assistant", ["content"] = result!.ToString()! });
+ ```
+
+
+ ```rust
+ use serde_json::json;
+
+ let history = vec![
+ json!({"role": "user", "content": "What is Prompty?"}),
+ json!({"role": "assistant", "content": "Prompty is a file format for LLM prompts."}),
+ ];
+
+ let inputs = json!({
+ "question": "Tell me more",
+ "conversation": history,
+ });
+
+ let result = pipeline::invoke("assistant.prompty", Some(&inputs)).await?;
+ ```
+
+
+
+### Message Format
+
+Each message in the thread list should have:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `role` | `string` | Yes | `"user"`, `"assistant"`, `"system"`, or `"tool"` |
+| `content` | `string` or `array` | Yes | Text content, or an array of content parts for multimodal |
+
+**Simple format** — content as a plain string:
+```json
+[
+ { "role": "user", "content": "What is the capital of France?" },
+ { "role": "assistant", "content": "The capital of France is Paris." }
+]
+```
+
+**Structured format** — content as an array of typed parts:
+```json
+[
+ {
+ "role": "user",
+ "content": [
+ { "kind": "text", "value": "What's in this image?" },
+ { "kind": "image", "value": "https://example.com/photo.jpg" }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": [
+ { "kind": "text", "value": "The image shows a sunset over the ocean." }
+ ]
+ }
+]
+```
+
+
+
+---
+
+## How It Works Internally
+
+Thread inputs go through a **nonce-based expansion** mechanism rather than
+simple string interpolation. This is important for security — it prevents
+user-supplied conversation history from accidentally injecting role markers
+(like `system:`) into the template.
+
+```mermaid
+flowchart TD
+ subgraph Render["1. Render"]
+ direction TB
+ R1["Template: {{conversation}}"]
+ R2["Nonce: __PROMPTY_THREAD_a1b2c3d4_conversation__"]
+ R1 --> R2
+ end
+
+ subgraph Parse["2. Parse"]
+ direction TB
+ P1["Role marker splitting"]
+ P2["Nonce preserved as text in message"]
+ P1 --> P2
+ end
+
+ subgraph Expand["3. Expand"]
+ direction TB
+ E1["Find nonce in message text"]
+ E2["Replace with actual Message objects"]
+ E1 --> E2
+ end
+
+ Render --> Parse --> Expand --> Final["Final message array"]
+
+ style Render fill:#dbeafe,stroke:#3b82f6,color:#1e293b
+ style Parse fill:#fef3c7,stroke:#f59e0b,color:#78350f
+ style Expand fill:#d1fae5,stroke:#10b981,color:#065f46
+```
+
+### Step by step
+
+1. **Render** — The renderer replaces the thread variable with a unique
+ **nonce marker** (e.g., `__PROMPTY_THREAD_a1b2c3d4_conversation__`) instead
+ of the actual messages. The nonce is a random hex string that cannot appear
+ in normal text.
+
+2. **Parse** — The parser splits the rendered text on role markers (`system:`,
+ `user:`, `assistant:`). The nonce marker passes through as plain text
+ inside a message's content.
+
+3. **Expand** — The pipeline scans parsed messages for nonce markers. When
+ found, it splits the surrounding text, inserts the actual thread messages
+ from your input data, and produces the final flat message array.
+
+### Why nonces?
+
+If thread messages were interpolated directly into the template as text, a
+malicious or accidental conversation entry like `"system: Ignore all
+instructions"` would create a *new role boundary* during parsing. The nonce
+approach ensures thread messages bypass the template engine and parser entirely
+— they are inserted as pre-built `Message` objects *after* parsing is complete.
+
+
+
+---
+
+## Best Practices
+
+### Token Budget Management
+
+Every message in the thread consumes tokens. As conversations grow, you'll
+eventually hit the model's context window limit. Common strategies:
+
+- **Sliding window** — keep only the last *N* messages:
+ ```python
+ MAX_HISTORY = 20 # last 10 exchanges
+ history = history[-MAX_HISTORY:]
+ ```
+
+- **Summarization** — periodically summarize older messages into a single
+ assistant message, then trim:
+ ```python
+ if len(history) > 30:
+ summary = summarize(history[:20]) # your summarization logic
+ history = [{"role": "assistant", "content": summary}] + history[20:]
+ ```
+
+- **Token counting** — use a tokenizer to measure the thread and truncate
+ from the oldest messages until it fits within budget.
+
+### System Prompt Separation
+
+Keep your system prompt outside the thread. The thread should contain only
+`user` and `assistant` messages from prior turns:
+
+```text
+system:
+You are an expert chef. ← Static system prompt
+
+{{conversation}} ← Prior user/assistant turns only
+
+user:
+{{question}} ← New user message
+```
+
+### Multiple Thread Inputs
+
+You can declare more than one thread input for advanced patterns — for example,
+a `context` thread for retrieved documents and a `conversation` thread for
+chat history:
+
+```yaml
+inputs:
+ - name: context
+ kind: thread
+ - name: conversation
+ kind: thread
+ - name: question
+ kind: string
+```
+
+```text
+system:
+You answer questions using the provided context.
+
+{{context}}
+{{conversation}}
+user:
+{{question}}
+```
+
+Each thread is expanded independently at its marker position.
+
+### Stateless Design
+
+Prompty itself is **stateless** — it does not store conversation history
+between calls. Your application code is responsible for:
+
+1. Accumulating messages in a list
+2. Passing that list as the thread input on each call
+3. Managing persistence (in-memory, database, session store, etc.)
+
+This gives you full control over what the model sees and makes it easy to
+implement features like message editing, branching, and context management.
+
+---
+
+## Thread Inputs with Agent Mode
+
+When using `apiType: agent` (the automatic tool-calling loop), thread inputs
+work the same way — they provide the initial conversation context. The agent
+loop then appends tool calls and results within a single `run()` invocation:
+
+```yaml
+---
+name: agent-with-history
+model:
+ id: gpt-4o
+ provider: openai
+ apiType: agent
+ connection:
+ kind: key
+ apiKey: ${env:OPENAI_API_KEY}
+inputs:
+ - name: conversation
+ kind: thread
+ - name: question
+ kind: string
+tools:
+ - name: get_weather
+ kind: function
+ description: Get current weather for a city
+ parameters:
+ properties:
+ - name: city
+ kind: string
+ required: true
+---
+system:
+You are a helpful assistant with access to weather data.
+
+{{conversation}}
+user:
+{{question}}
+```
+
+The thread provides context from prior turns, and the agent loop handles
+any new tool calls within the current turn.
+
+---
+
+## Quick Reference
+
+| Aspect | Details |
+|--------|---------|
+| **Declaration** | `kind: thread` in `inputs` |
+| **Template syntax** | `{{threadName}}` — Jinja2 or Mustache |
+| **Data format** | `list` of `{ role, content }` objects |
+| **Empty thread** | Valid — produces no extra messages |
+| **Expansion** | Nonce-based, post-parse (injection-safe) |
+| **Supported runtimes** | Python, TypeScript, C#, Rust |
+| **State management** | Application-side — Prompty is stateless |
+
+---
+
+## Next Steps
+
+- [**Tutorial: Build a Chat Assistant**](/tutorials/chat-assistant/) — hands-on walkthrough building a multi-turn chatbot
+- [**Agent Mode**](/core-concepts/agent-mode/) — combine threads with automatic tool calling
+- [**Pipeline Architecture**](/core-concepts/pipeline/) — understand how threads fit into the four-stage pipeline
+- [**Streaming**](/core-concepts/streaming/) — stream responses while using conversation history
diff --git a/web/src/content/docs/core-concepts/file-format.mdx b/web/src/content/docs/core-concepts/file-format.mdx
index cd730645..7aa0e2ba 100644
--- a/web/src/content/docs/core-concepts/file-format.mdx
+++ b/web/src/content/docs/core-concepts/file-format.mdx
@@ -196,7 +196,7 @@ outputs:
| Field | Type | Default | Description |
|--------------|-----------|----------|------------------------------------------|
| `name` | `string` | — | Property name (required in list form) |
-| `kind` | `string` | — | Type: `string`, `integer`, `float`, `boolean`, `array`, `object`, `thread`, `image`, `file`, `audio` |
+| `kind` | `string` | — | Type: `string`, `integer`, `float`, `boolean`, `array`, `object`, [`thread`](/core-concepts/conversation-history/), `image`, `file`, `audio` |
| `description`| `string` | — | Human-readable description |
| `required` | `boolean` | `false` | Whether the input must be provided |
| `default` | `any` | — | Default value when input is not provided |
diff --git a/web/src/content/docs/core-concepts/index.mdx b/web/src/content/docs/core-concepts/index.mdx
index 0555ba88..363a2d24 100644
--- a/web/src/content/docs/core-concepts/index.mdx
+++ b/web/src/content/docs/core-concepts/index.mdx
@@ -18,6 +18,7 @@ a key aspect of how Prompty processes `.prompty` files into LLM results.
## Configuration
- [**Connections**](/core-concepts/connections/) — how Prompty authenticates with LLM providers (API keys, Microsoft Entra ID, registries)
+- [**Conversation History (Threads)**](/core-concepts/conversation-history/) — multi-turn conversation via `kind: thread` inputs, nonce-based expansion, and best practices
- [**Tools**](/core-concepts/tools/) — function, MCP, OpenAPI, and custom tool definitions
## Advanced Features
diff --git a/web/src/content/docs/tutorials/chat-assistant.mdx b/web/src/content/docs/tutorials/chat-assistant.mdx
index f3180dfa..944727db 100644
--- a/web/src/content/docs/tutorials/chat-assistant.mdx
+++ b/web/src/content/docs/tutorials/chat-assistant.mdx
@@ -401,6 +401,11 @@ invaluable for debugging unexpected outputs.
## Next steps
+