Skip to content

Custom Blocks

Custom blocks let model adapters intercept XML-like tags in the model's text output, strip them from the visible text stream, and handle them as structured content blocks.

Overview

When an LLM outputs text containing registered XML tags like <interpretation>insight</interpretation> or <done/>, the adapter:

  1. Strips the tags from the text stream — downstream consumers see clean text
  2. Emits streaming events (custom_block_start, custom_block_delta, custom_block_end) for real-time UI
  3. Accumulates complete blocks as CustomContentBlock in message.content, preserving temporal position relative to text

Quick Start

typescript
import { createAdapter, StopReason } from "@agentick/core/model";

const model = createAdapter({
  metadata: { id: "my-model", provider: "my-provider", capabilities: [] },
  prepareInput: (input) => ({ ... }),
  mapChunk: (chunk) => ({ type: "text", delta: chunk.text }),
  execute: (input) => provider.generate(input),
  executeStream: (input) => provider.stream(input),

  // Define custom blocks
  customBlocks: {
    interpretation: {
      description: "Analytical insight.",
      instructions: "Wrap insights when synthesizing information.",
    },
    done: {
      description: "Signals task completion.",
      transform() { return []; },   // suppress from output
    },
    debugInfo: {
      tag: "debug-info",            // remap XML tag
    },
  },
});

CustomBlockDefinition

Each key in customBlocks is a semantic name used in application code. The definition controls interception and handling:

typescript
interface CustomBlockDefinition {
  /** XML tag to intercept. Defaults to the config key. */
  tag?: string;

  /** Short description of what this block represents. Auto-injected into system prompt. */
  description?: string;

  /** Detailed usage instructions for the model. Appended after description in system prompt. */
  instructions?: string;

  /** Transform the complete block before accumulation.
   *  - Return void → passthrough as CustomContentBlock
   *  - Return AdapterDelta[] → emit these instead ([] suppresses) */
  transform?(block: CustomBlockInput): AdapterDelta[] | void;

  /** Called when opening tag found, before content arrives. */
  onStart?(attrs: Record<string, string>): void;
}

Passthrough (default)

The simplest form — tags are intercepted and accumulated as CustomContentBlock:

typescript
customBlocks: {
  citation: {},
}

Model outputs text<citation url="...">quote</citation>more → content array has [TextBlock("text"), CustomContentBlock(tag: "citation", ...), TextBlock("more")].

Transform

Rewrite a custom block into different deltas:

typescript
customBlocks: {
  interpretation: {
    transform(block) {
      // Rewrite as visible text
      return [{ type: "text", delta: `[${block.content}]` }];
    },
  },
}

Suppress

Consume as a side effect and remove from output:

typescript
customBlocks: {
  done: {
    transform(block) {
      continuationFlag.set(true);
      return []; // empty array = suppress
    },
  },
}

Tag Remapping

Use a different XML tag than the config key:

typescript
customBlocks: {
  debugInfo: {
    tag: "debug-info", // intercepts <debug-info>, key is "debugInfo"
  },
}

The resulting CustomContentBlock.tag will be "debugInfo" (the config key), not "debug-info" (the XML tag).

Model Instructions (description + instructions)

The description and instructions fields tell the model what a block is and how to use it. The adapter automatically appends this to the system prompt — no manual prompt maintenance needed.

description is a short label for the tag listing. instructions elaborates on when and how to use it. Either field alone triggers injection; both together produce a two-line entry:

typescript
customBlocks: {
  interpretation: {
    description: "Analytical insight derived from evidence.",
    instructions: "Use when synthesizing information or drawing conclusions. Do not use for direct observations.",
  },
  done: {
    description: "Signals task completion.",
    transform() { return []; },
  },
  citation: {
    instructions: "Quote relevant passages from sources using <citation url=\"...\">text</citation>.",
  },
}

The adapter collects all blocks with description or instructions and appends a single text block to the last system message:

You can use the following XML tags in your output:
- <interpretation>: Analytical insight derived from evidence.
  Use when synthesizing information or drawing conclusions. Do not use for direct observations.
- <done>: Signals task completion.
- <citation>: Quote relevant passages from sources using <citation url="...">text</citation>.

If no system message exists, one is created. Blocks with neither field are still intercepted and processed — they just don't get system prompt documentation.

CustomContentBlock

Custom blocks are stored in the message content array as CustomContentBlock, preserving temporal position:

typescript
interface CustomContentBlock {
  readonly type: "custom";
  readonly tag: string;
  readonly content: string;
  readonly attrs: Record<string, string>;
  readonly selfClosing?: boolean;
}

Use the isCustomBlock type guard:

typescript
import { isCustomBlock } from "@agentick/shared";

for (const block of message.content) {
  if (isCustomBlock(block)) {
    switch (block.tag) {
      case "interpretation":
        renderInterpretation(block.content, block.attrs);
        break;
      case "citation":
        renderCitation(block.content, block.attrs);
        break;
    }
  }
}

Streaming Events

Custom blocks emit four event types during streaming:

EventFieldsWhen
custom_block_starttag, attrsOpening tag detected
custom_block_deltatag, deltaContent chunk within tag
custom_block_endtagClosing tag detected
custom_blocktag, content, attrs, selfClosing?Complete block (triggers accumulation)

The lifecycle events (start/delta/end) are for real-time UI updates. The complete custom_block event triggers accumulation into the content array.

Delta Transforms

Custom blocks are built on a lower-level abstraction: delta transforms. These are streaming middleware that sit between mapChunk and StreamAccumulator in the adapter pipeline.

typescript
interface DeltaTransform {
  process(delta: AdapterDelta): AdapterDelta[];
  flush(): AdapterDelta[];
}

Pipeline Order

provider chunk → mapChunk() → AdapterDelta
  → custom blocks extraction (implicit)
    → user deltaTransform
      → StreamAccumulator → StreamEvent

Custom blocks always run first. User deltaTransform receives clean text with tags already stripped.

Composing Transforms

typescript
import { composeDeltaTransforms } from "@agentick/core/model";

createAdapter({
  ...options,
  // Array form — auto-composed
  deltaTransform: [markdownBufferer, contentRewriter],
});

// Or explicit composition
const pipeline = composeDeltaTransforms(transform1, transform2);

On flush, upstream flush output cascades through downstream process() before each downstream flushes its own state.

StreamTagParser (Advanced)

For direct control over tag parsing, use StreamTagParser as a delta transform:

typescript
import { StreamTagParser } from "@agentick/core/model";

const parser = new StreamTagParser({
  tags: {
    think: {
      onStart(attrs) {},
      onContent(content, attrs) {},
      onSelfClosing(attrs) {},
    },
  },
});

createAdapter({ ...options, deltaTransform: parser });

This is the lower-level API that the customBlocks config builds on internally.

Released under the ISC License.