Custom parsers

Contents

Custom parsers are in beta

Custom parsers are currently in beta. We'd love to hear your feedback as we develop this feature.

PostHog renders your LLM traces, spans, and generations as a sequence of chat-style messages — user prompts, assistant replies, tool calls, and their results. To do this, it normalizes the raw input and output captured from your application into a canonical message shape.

PostHog ships with built-in recipes for the common provider and framework formats (OpenAI, Anthropic, LangChain, LiteLLM, the Vercel AI SDK, OpenTelemetry, and more). When none of them recognizes the shape of an event, that event falls back to displaying its raw JSON instead of a readable conversation.

A custom parser recipe is a small set of rules, written in YAML, that teaches PostHog how to turn one of these unrecognized shapes into messages. Recipes are scoped to your team and apply whenever a trace is rendered, so once you add one, every matching event displays correctly — past and future.

Let PostHog AI write it for you

The fastest way to create a recipe is to let PostHog AI write it from the event in front of you. You don't need to learn the syntax to do this.

  1. Open a trace or generation that's displaying as raw JSON.
  2. Use the Set up a custom parser action to ask PostHog AI to fix the rendering.
  3. PostHog AI reads the event's input and output. When the mapping is obvious it writes a recipe and validates it against that exact event; when the data isn't really a conversation — a state object, a config blob, or a metrics dump — it describes the options it sees and asks how you'd like it displayed.
  4. Once validation passes, the recipe is saved to your team and the open trace re-renders immediately.
AI data processing

Asking PostHog AI to write a recipe sends the event's input and output to an LLM. This requires AI data processing to be enabled for your organization, the same requirement as trace summarization.

Managing recipes

Your team's recipes live in SettingsAI observabilityCustom parsing. From there you can:

  • Add recipe — write a new recipe by hand (see the reference below).
  • Edit — change an existing recipe.
  • Delete — permanently remove a recipe.

Recipes you create here are applied to the live trace view as soon as they're saved, so you can iterate against a real event.

How recipes are applied

A recipe is an ordered list of rules. When PostHog renders an event, it feeds the input and output through the rules and produces messages. A few things are worth knowing before you write one:

  • Rules are tried in order, first match wins. Order your rules from most specific to most general.
  • Built-ins run first, so you only need to write rules for the shapes that are still unrecognized.
  • Top-level arrays are unwrapped automatically, so write rules for the element shapes, not the array.
  • Anything still unrecognized keeps its raw JSON view, so a recipe never breaks an event that already renders.

Each message a recipe produces has a role (user, assistant, system, tool, thinking, or tool_result), content (a string or a list of content blocks), and optionally tool calls and a tool call ID that links a result back to the call it answers.

Recipe reference

A recipe is YAML with a single rules: sequence. Each rule has a pattern (on:) that decides when it fires and exactly one output mode (emit, delegate, or delegateEach):

YAML
rules:
- on: <pattern> # when the rule fires
emit | delegate | delegateEach: ... # what it produces (exactly one)
stamp: <emit slots> # delegateEach only: override role/toolCallId on results
followups: [...] # extra messages appended after the rule's output

Patterns

A pattern is a mapping of field: predicate. Every entry must hold for the rule to fire. A bare value is an equality check; the verb forms cover everything else.

PredicateSyntaxMatches when
Equalityfield: valueThe field equals the value exactly
Existencefield: { exists: true }The field is present (false for absent)
Typefield: { is: string }The field is of that type
Type (any of)field: { is: [string, null] }The field is one of the listed types
Membershipfield: { in: [a, b, c] }The field's value is in the set
Shapefield: { shape: {...} }The object field matches a nested pattern
Everyfield: { every: <predicate> }Every element of the array field satisfies the predicate
Whole input$: <predicate>The predicate applied to the entire input holds

Nesting a mapping under a field is shorthand for shapesource: { data: { is: string } } matches an object source whose data is a string.

Valid types for is are string, array, object, null, number, boolean, and any.

YAML
on:
role: { exists: true }
content: { is: [string, null] }

Value expressions

Expressions produce the values you put into output slots, delegate sources, and operator arguments.

ExpressionExampleResult
Whole input$The entire input object
Field path$.function.argumentsThe value at that path, or undefined if missing
String literal"Hello"The string as-is
Interpolation"Model: $.model"Field values spliced into the string
Number / boolean123, trueThe literal value
Array[$.a, $.b]Each element compiled in turn
Object{ name: $.name, id: $.id }Each field compiled in turn

A $ is only read as a field path when followed by an identifier ($.model), so a literal $5.00 in a string is left alone.

Operators

Operators are one-key objects that transform values.

OperatorSyntaxPurpose
coalescecoalesce: [expr1, expr2, ...]First non-null, non-undefined value
selectselect: { from: <arr>, where?: <pattern>, pluck?: <expr>, if_empty?: <expr> }Filter an array, optionally transforming each element
rejectreject: { from: <arr>, where: <pattern>, if_empty?: <expr> }Inverse filter — keep the elements that don't match
joinjoin: { from: <arr>, sep?: <str>, field?: <str> }Join an array into a string (default separator is a newline)
omitomit: { from: <obj>, keys: [k1, k2, ...] }An object with the listed keys removed
stringifystringify: <expr>JSON-encode a non-string value for display
try_parse_structured_contenttry_parse_structured_content: <expr>Parse a stringified '[{"type": "text", ...}]' block array
literalliteral: <value>Keep a value uncompiled — escapes a one-key object whose key happens to be an operator name

On select and reject, if_empty: ~ drops the slot entirely instead of emitting an empty array.

Output: emit slots

emit builds a single message from these slots. Use the same slots in stamp and in followups.

SlotValueBehavior
roleA role tag or expressionDefaults to the input's own role/type (normalized: human → user, ai/model/bot → assistant), falling back to user on the input side and assistant on the output side
contentAn expression → string or list of content blocksThe message body
toolCall{ id: ..., name: ..., args: ... }A single tool/function call
toolCallsAn expression → array of callsMultiple tool calls
toolCallIdAn expression → stringLinks a tool_result message back to the call it answers
spreadAn expression → objectSeed the message from an object's fields; explicit slots override what's spread in

YAML
emit:
role: assistant
content: $.text
toolCall:
id: $.call_id
name: $.action.tool
args: $.action.params

Output: delegate and delegateEach

Instead of emitting a message directly, a rule can hand a nested value back to the recipe to be re-matched:

  • delegate: <expr> — unwrap a single nested value and re-match it against all rules.
  • delegateEach: <expr> — unwrap an array and re-match each element. Add stamp: to override role or toolCallId on every resulting message (useful for tagging a batch of tool results with their parent call's ID).
YAML
- on:
transcript: { is: array }
delegateEach: $.transcript

Followups

After a rule emits or delegates, followups: appends extra messages. A followup is either static (one message) or an expansion over an array (from/each, one message per element):

YAML
followups:
- role: system # static: one message
content: "Done"
- from: $.attachments # expand: one message per element
each:
role: system
content: "Attached: $.name"

Examples

Map custom fields onto roles and content

A simple shape where a kind field decides the role and the text lives under body:

YAML
rules:
- on:
kind: question
body: { exists: true }
emit:
role: user
content: $.body.text
- on:
kind: answer
emit:
role: assistant
content: $.body.text

Unwrap an envelope and normalize each entry

Unwrap a transcript array, then match each entry by who sent it:

YAML
rules:
- on:
transcript: { is: array }
delegateEach: $.transcript
- on:
who: { in: [customer, visitor] }
emit:
role: user
content: $.text
- on:
who: agent
emit:
role: assistant
content: $.text

Tool calls, results, and thinking

Map a tool invocation, its result, and a reasoning step to the right roles:

YAML
rules:
- on:
action: { shape: { tool: { is: string } } }
emit:
role: assistant
toolCall:
id: $.call_id
name: $.action.tool
args: $.action.params
- on:
result_for: { exists: true }
emit:
role: tool_result
toolCallId: $.result_for
content:
stringify: $.output
- on:
reasoning: { exists: true }
emit:
role: thinking
content: $.reasoning

Split content blocks into text and tool calls

Use reject and join to gather the text parts, and select to pull out the tool calls. The role is omitted, so it's inherited from the input's own role field:

YAML
rules:
- on:
role: { is: string }
parts: { every: { shape: { type: { is: string } } } }
emit:
content:
join:
from:
reject: { from: $.parts, where: { type: call } }
sep: "\n"
field: value
toolCalls:
select:
from: $.parts
where: { type: call }
pluck: { id: $.id, name: $.tool, args: $.args }
if_empty: ~

Delegate a nested payload and append a note

Delegate the real prompt out of a request envelope, parse its structured content, and append the model name as a system message:

YAML
rules:
- on:
request: { shape: { prompt: { exists: true } } }
delegate: $.request
followups:
- role: system
content: "Model: $.model"
- on:
prompt: { exists: true }
emit:
role: user
content:
try_parse_structured_content: $.prompt

Tips

  • Start from the sample. Write on: patterns that match the exact field names and shapes you see in the raw JSON.
  • Most specific first. Because the first matching rule wins, put narrow rules above broad ones.
  • Pick the representation that fits. Tool activity belongs in toolCall/tool_result messages, not prose pretending to be dialogue. Standalone data with no message structure can be a single concise system message built from its key fields.
  • Let PostHog AI do the first draft. Even if you plan to hand-tune, starting from a generated recipe against your real event is usually faster than writing from scratch.

Community questions

Was this page useful?