A conversational time series forecasting assistant with Amazon Bedrock and LibreChat

Dashboards and reports remain the default interface for most forecasting systems, but they limit users to prespecified queries and views. In this post, we walk through the implementation of a conversational time series forecasting assistant built with ClickHouse, Amazon Bedrock, and LibreChat. This approach enables users to explore time series data interactively and adjust forecast parameters — such as quantile levels and prediction horizons — through natural language.

The solution is built around the Model Context Protocol (MCP), which defines a standard interface for connecting language models to external tools and services. Three MCP servers handle the core functionality: the official ClickHouse MCP server for data retrieval, a custom forecasting server wrapping Chronos – a time series foundation model developed by Amazon – on Amazon Bedrock, and a custom data visualization server for interactive Plotly charts. Claude Sonnet 4.6 on Amazon Bedrock orchestrates the tool calls.

The video below shows the assistant handling a retail sales forecasting scenario: exploring the available product sales time series, plotting a product’s hourly sales over the most recent week, forecasting the next 48 hours, and repeating the analysis for additional products, with all results shown in a single combined chart.

Architecture

The three MCP servers are configured as Docker services in docker-compose.override.yml, alongside the LibreChat api service. The ClickHouse server uses the official mcp/clickhouse image; the forecasting and visualization servers are built from custom Docker images described below.

services:
  api:
    volumes:
      - ./librechat.yaml:/app/librechat.yaml
  mcp-clickhouse:
    image: mcp/clickhouse
    container_name: mcp-clickhouse
    ports:
      - 8001:8000
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - CLICKHOUSE_HOST=<CLICKHOUSE_HOST>
      - CLICKHOUSE_USER=<CLICKHOUSE_USER>
      - CLICKHOUSE_PASSWORD=<CLICKHOUSE_PASSWORD>
      - CLICKHOUSE_MCP_SERVER_TRANSPORT=sse
      - CLICKHOUSE_MCP_BIND_HOST=0.0.0.0
  chronos-forecasting:
    build:
      context: ./chronos-forecasting
    ports:
      - "8002:8002"
    environment:
      - AWS_DEFAULT_REGION=eu-west-1
      - AWS_ACCESS_KEY_ID=<AWS_ACCESS_KEY_ID>
      - AWS_SECRET_ACCESS_KEY=<AWS_SECRET_ACCESS_KEY>
  data-visualization:
    build:
      context: ./data-visualization
    ports:
      - "8003:8003"
      - "8004:8004"
    volumes:
      - ./plots:/plots

Note that the visualization server exposes two ports: 8003 for the MCP server (SSE transport) and 8004 for the HTTP file server that serves the generated HTML charts. The /plots directory is mounted as a volume so charts persist across container restarts.

The servers are registered in librechat.yaml using SSE transport:

mcpServers:
  clickhouse-playground:
    type: sse
    url: http://host.docker.internal:8001/sse
  chronos-forecasting:
    type: sse
    url: http://host.docker.internal:8002/sse
  data-visualization:
    type: sse
    url: http://host.docker.internal:8003/sse

The LibreChat modelSpecs object ties the model, the MCP servers, and the system prompt together into a named assistant configuration:

modelSpecs:
  list:
    - name: "forecasting-assistant"
      label: "Forecasting Assistant"
      default: true
      artifacts: true
      mcpServers:
        - "clickhouse-playground"
        - "chronos-forecasting"
        - "data-visualization"
      preset:
        endpoint: "bedrock"
        model: "eu.anthropic.claude-sonnet-4-6"
        region: "eu-west-1"
        promptPrefix: |
          You are a time series forecasting assistant.
          Do not alter the time frequency of the data unless the user explicitly requests it.
          To visualize data, always use the `visualize_data` tool. Never write code to generate plots.
          When visualizing multiple series, always pass them together to the `visualize_data` tool in a single call,
          as it generates subplots automatically.
          Always render the URL returned by the `visualize_data` tool as an artifact using this exact format:
          :::artifact{identifier="<IDENTIFIER>" type="text/html" title="<TITLE>"}
          ```
          <!DOCTYPE html>
          <html>
          <body>
            <iframe src="<URL>" width="100%" height="800px" frameborder="0"></iframe>
          </body>
          </html>
          ```
          :::
          Never return the raw URL and never ask the user to open the URL themselves.

The system prompt instructs the model to wrap chart URLs in LibreChat Artifacts format, so the LibreChat UI renders the Plotly chart directly rather than displaying a raw localhost URL.

Forecasting MCP Server

The forecasting server is built with FastMCP and exposes a single generate_forecasts tool. It invokes the Chronos endpoint deployed on Amazon Bedrock via boto3, passing the historical values as context along with the desired prediction length and quantile levels.

Since Chronos performs zero-shot forecasting without fine-tuning, the server works out of the box on any time series. For instructions on deploying Chronos on Amazon Bedrock, see this previous post.

server.py

import json
import boto3
from mcp.server.fastmcp import FastMCP

# Create the FastMCP server
mcp = FastMCP(
    name="chronos-forecasting",
    host="0.0.0.0",
    port=8002
)

# Register the tool with the FastMCP server
@mcp.tool()
def generate_forecasts(
    target: list[float],
    prediction_length: int,
    quantile_levels: list[float]
) -> dict:
    """
    Generate probabilistic time series forecasts using Chronos on Amazon Bedrock.

    Parameters:
    ===============================================================================
    target: list of float.
        The historical time series values used as context.

    prediction_length: int.
        The number of future time steps to predict.

    quantile_levels: list of float.
        The quantiles to be predicted at each future time step.

    Returns:
    ===============================================================================
    dict
        Dictionary with predicted mean and quantiles at each future time step.
    """
    # Create the Bedrock client
    bedrock_runtime_client = boto3.client("bedrock-runtime")

    # Invoke the Bedrock endpoint
    response = bedrock_runtime_client.invoke_model(
        modelId="<bedrock-endpoint-arn>",
        body=json.dumps({
            "inputs": [{
                "target": target
            }],
            "parameters": {
                "prediction_length": prediction_length,
                "quantile_levels": quantile_levels
            }
        })
    )

    # Extract the forecasts
    forecasts = json.loads(response["body"].read()).get("predictions")[0]

    # Return the forecasts
    return forecasts


# Run the FastMCP server with SSE transport
if __name__ == "__main__":
    mcp.run(transport="sse")

The server’s requirements.txt and Dockerfile are reported below:

requirements.txt

mcp
boto3

Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
CMD ["python", "server.py"]

Data Visualization MCP Server

The data visualization server is also built with FastMCP. It exposes a visualize_data tool that takes historical data and optional forecast outputs, builds a Plotly figure with one subplot per time series, saves it as a self-contained HTML file, and returns the URL. A lightweight HTTP server running in a background thread serves the /plots directory on port 8004.

Using a dedicated MCP server for data visualization — rather than having the model generate chart code directly — ensures consistent styling and reproducible outputs across sessions, which matters when the assistant is used for reporting.

The tool input schema is designed to accept the raw output of ClickHouse queries directly, so the model does not need to reformat data between tool calls:

server.py

import os
import uuid
import threading
import http.server
import functools
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from mcp.server.fastmcp import FastMCP

# Create the FastMCP server
mcp = FastMCP(
    name="data-visualization",
    host="0.0.0.0",
    port=8003
)

# Register the tool with the FastMCP server
@mcp.tool()
def visualize_data(
    inputs: dict
) -> str:
    """
    Plot one or more time series with optional forecasts and return
    the URL of the interactive HTML chart.

    Parameters
    ===============================================================================
    inputs : dict
        A dictionary with the following keys:

        "data" (required) : dict
            A dictionary where each key is a series name and each value is the
            raw output of a ClickHouse query, with the following fields:
                - "columns" : list of strings, must contain "timestamp" and one value column
                - "rows"    : list of [timestamp_str, float] pairs

            Example:
            {
                "series_1": {
                    "columns": ["timestamp", "<VALUE>"],
                    "rows": [
                        ["2026-01-01", 1.0],
                        ["2026-01-02", 2.0]
                    ]
                },
                "series_2": {
                    "columns": ["timestamp", "<VALUE>"],
                    "rows": [
                        ["2026-01-03", 3.0],
                        ["2026-01-04", 4.0]
                    ]
                },
            }

        "forecasts" (optional) : dict
            Forecasts for the same time series in "data". Each key is a series
            name matching a key in "data", and each value is a dictionary with
            the following fields:
                - "timestamp"  : list of strings representing datetimes
                - "mean"       : list of floats (mean forecast)
                - "<quantile>" : list of floats for each quantile level, e.g.
                                 "0.05" and "0.95" for a 90% prediction interval.

            Example:
            {
                "series_1": {
                    "timestamp": ["2026-01-01", "2026-01-02"],
                    "mean": [1.0, 2.0],
                    "0.1": [0.5, 1.5],
                    "0.5": [1.0, 2.0],
                    "0.9": [1.5, 2.5],
                },
                "series_2": {
                    "timestamp": ["2026-01-03", "2026-01-04"],
                    "mean": [3.0, 4.0],
                    "0.1": [2.5, 3.5],
                    "0.5": [3.0, 4.0],
                    "0.9": [3.5, 4.5],
                },
            }

    Returns
    ===============================================================================
    str
        The URL of the interactive HTML chart.
    """
    # Extract the data and forecasts
    data = inputs["data"]
    forecasts = inputs.get("forecasts", {})

    # Parse the data
    parsed_data = {}
    for series, query_result in data.items():
        value_col = [c for c in query_result["columns"] if c != "timestamp"][0]
        idx = query_result["columns"].index
        parsed_data[series] = {
            "timestamp": [row[idx("timestamp")] for row in query_result["rows"]],
            "values": [row[idx(value_col)] for row in query_result["rows"]]
        }

    # Create the figure
    fig = make_subplots(
        rows=len(data),
        subplot_titles=list(data.keys())
    )

    # Update the figure layout
    fig.update_layout(
        height=250 * len(data),
        paper_bgcolor="white",
        plot_bgcolor="white",
        margin=dict(t=50, b=50, l=50, r=50),
        hovermode="x unified",
        hoverlabel=dict(
            namelength=-1
        ),
        legend=dict(
            font=dict(
                color="#24292f",
                size=12
            ),
        )
    )

    # Update the subplots titles
    fig.update_annotations(
        font=dict(
            color="#24292f",
            size=14
        ),
    )

    # Generate the subplots
    for i, series in enumerate(data):
        # Plot the forecasts
        if series in forecasts:
            # Extract the predicted quantiles
            q = sorted([float(k) for k in forecasts[series] if k not in ("mean", "timestamp")])

            # Extract the lower and upper bound of the prediction interval
            q_min, q_max = q[0], q[-1]

            # Plot the upper bound of the prediction interval
            fig.add_trace(
                go.Scatter(
                    x=forecasts[series]["timestamp"],
                    y=forecasts[series][str(q_max)],
                    name=f"Predicted Q{q_max:,.1%}",
                    hovertemplate="%{fullData.name}: %{y:,.0f}<extra></extra>",
                    showlegend=False,
                    mode="lines",
                    line=dict(
                        width=0.5,
                        color="#c2e5ff",
                    ),
                ),
                row=i + 1,
                col=1
            )

            # Plot the lower bound of the prediction interval
            fig.add_trace(
                go.Scatter(
                    x=forecasts[series]["timestamp"],
                    y=forecasts[series][str(q_min)],
                    name=f"Predicted Q{q_min:,.1%}",
                    hovertemplate="%{fullData.name}: %{y:,.0f}<extra></extra>",
                    showlegend=False,
                    mode="lines",
                    line=dict(
                        width=0.5,
                        color="#c2e5ff",
                    ),
                    fillcolor="#c2e5ff",
                    fill="tonexty",
                ),
                row=i + 1,
                col=1
            )

            # Plot the predicted median if available, otherwise fall back to the predicted mean
            fig.add_trace(
                go.Scatter(
                    x=forecasts[series]["timestamp"],
                    y=forecasts[series]["0.5" if 0.5 in q else "mean"],
                    name=f"Predicted {'Median' if 0.5 in q else 'Mean'}",
                    hovertemplate="%{fullData.name}: %{y:,.0f}<extra></extra>",
                    showlegend=i == 0,
                    mode="lines",
                    line=dict(
                        color="#0588f0",
                        width=1,
                        dash="dot"
                    )
                ),
                row=i + 1,
                col=1
            )

        # Plot the data
        fig.add_trace(
            go.Scatter(
                x=parsed_data[series]["timestamp"],
                y=parsed_data[series]["values"],
                name="Historical Data",
                hovertemplate="%{fullData.name}: %{y:,.0f}<extra></extra>",
                mode="lines",
                showlegend=i == 0,
                line=dict(
                    color="#838383",
                    width=1
                )
            ),
            row=i + 1,
            col=1
        )

        # Update the subplot's x-axis
        fig.update_xaxes(
            type="date",
            tickformat="%b %d %Y<br>(%a) %H:%M",
            tickangle=0,
            mirror=True,
            linecolor="#cecece",
            gridcolor="#e8e8e8",
            gridwidth=0.5,
            tickfont=dict(
                color="#24292f",
                size=10
            ),
            row=i + 1,
            col=1
        )

        # Update the subplot's y-axis
        fig.update_yaxes(
            tickformat=",.0f",
            mirror=True,
            linecolor="#cecece",
            gridcolor="#e8e8e8",
            gridwidth=0.5,
            tickfont=dict(
                color="#24292f",
                size=10
            ),
            row=i + 1,
            col=1
        )

    # Save the figure to an HTML file
    os.makedirs("/plots", exist_ok=True)
    filename = f"plot_{uuid.uuid4().hex}.html"
    fig.write_html(f"/plots/{filename}", full_html=True, include_plotlyjs="cdn")

    # Return the URL of the HTML file
    return f"http://localhost:8004/{filename}"


if __name__ == "__main__":
    # Serve the /plots directory over HTTP on port 8004
    def _serve_plots():
        handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory="/plots")
        with http.server.HTTPServer(("0.0.0.0", 8004), handler) as httpd:
            httpd.serve_forever()


    # Start the HTTP server
    threading.Thread(target=_serve_plots, daemon=True).start()

    # Run the FastMCP server with SSE transport
    mcp.run(transport="sse")

The requirements.txt and Dockerfile for this server follow the same pattern as the forecasting server:

requirements.txt

mcp
plotly

Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
CMD ["python", "server.py"]

The full implementation walkthrough, assistant configuration, and chat transcript are available on flaviagiammarino.com.

I Built an AI Agent Safety Net in 48 Hours — Here’s Why Every Vibe Coder Needs One

If you’re building AI agents with Cursor, Replit, or ChatGPT —
your agent can send emails, delete data, and spend money
without asking you first.

I learned this the hard way.

The problem

I built an agent that worked great in testing. Then I realized:
there’s nothing stopping it from sending emails to real people
with hallucinated data, processing duplicate payments, or
deleting records it shouldn’t touch.

I looked for a simple safety layer. Everything I found was
either enterprise compliance software ($$$) or required
rewriting my entire agent architecture.

So I built one

pip install autonomica

Then add one line above any function your agent can call:

from autonomica import govern

@govern(agent_id="my-bot")
def send_email(to, subject, body):
    # your existing code — nothing changes
    email_api.send(to, subject, body)

That’s it. Autonomica now watches every call and decides:

Risk level What happens Example
🟢 Low Goes through automatically Reading a database
🔵 Medium Goes through + you get alerted Sending an email
🟡 High Pauses for your approval $75K payment
🔴 Critical Blocked until you review Deleting records

The cool part: it learns

New agent → everything gets flagged (like a new employee on day 1)
After 50 good actions → it trusts routine tasks
Makes a mistake → guardrails tighten automatically

You don’t configure any of this. It adapts.

The nerdy part (optional)

The architecture is inspired by the human autonomic nervous system.
Your brain makes ~35,000 decisions daily — 99.85% on autopilot,
only critical ones get escalated to conscious attention.
Autonomica does the same for your agents.

Numbers

  • Adds 0.1ms latency (invisible)
  • 436 tests passing
  • Apache 2.0 (free forever)
  • Works with any Python function, LangChain, CrewAI, AutoGen

Try it

pip install autonomica

GitHub: https://github.com/ai-singh07/autonomica

Built this in 48 hours with Claude Code, solo developer,
first open source project. Feedback welcome — especially
on what risk signals I’m missing.

I built an AI E-Commerce Brain with Notion MCP

preview of notion dashboard

This is a submission for the Notion MCP Challenge

What I Built

I built an AI e-commerce brain for a Shopify storefront using Notion MCP as the behavior control layer.

On the surface, it looks like one shopping assistant. Under the hood, it behaves more like a network of specialists:

  • a training bag specialist
  • a gloves and wraps specialist
  • a cart review specialist
  • an order history specialist
  • an add-to-cart specialist

The architecture is split intentionally.

The app keeps ownership of:

  • intent classification and routing
  • Shopify integration
  • ranking and filtering
  • session and conversation state
  • validation and fallback logic
  • performance and caching

Notion owns the behavior layer for each intent:

  • tone and voice
  • intent instructions
  • recommendation strategy
  • questions to ask
  • do / do not rules
  • example responses
  • behavior notes
  • synced intent synonyms

That means I can change how the AI behaves per scenario without rewriting prompts throughout the codebase.

Instead of one generic chatbot, I now have a commerce system made up of specialist branches like:

  • search_training_bags
  • search_gloves_wraps
  • cart_review
  • orders_selection
  • order_detail

This makes the behavior visible, editable, and operational.

preview of notion dashboard

I also built a publish model so Notion is safe to use in production:

  1. Edit behavior in Notion
  2. Click Sync from Notion
  3. Publish the behavior into the app
  4. Run the storefront from the published config locally for fast runtime performance

I also built the reverse flow:

  • Sync to Notion

So the app can push its current behavior definitions back into the Notion database.

This turned Notion from a documentation layer into a real control plane for the AI.

notion sync flow

Video Demo

In the demo, I walk through:

  • the Notion behavior control panel
  • the specialist intent rows
  • the sync workflow
  • changing behavior in Notion and seeing the storefront assistant respond differently
  • category-specific shopping flows like training bags and gloves/wraps

Show us the code

Parse Notion rows into behavior config

This is the layer that turns a Notion database row into a specialist behavior object the app can use at runtime.

function parseNotionBehaviorRow(page) {
  const properties = page?.properties || {};
  const intent = pickProperty(properties, ['intent', 'name', 'title']);
  const key = normalizeIntentKey(intent);
  if (!key) return null;

  const enabledRaw = pickProperty(properties, ['enabled', 'active']);
  const enabled = enabledRaw ? enabledRaw === 'true' : true;

  return {
    key,
    enabled,
    intentSynonyms: pickProperty(properties, ['intent_synonyms', 'synonyms', 'trigger_phrases']),
    toneVoice: pickProperty(properties, ['tone_voice', 'tone', 'voice']),
    intentInstructions: pickProperty(properties, ['intent_instructions', 'instructions']),
    questionsToAsk: pickProperty(properties, ['questions_to_ask', 'questions']),
    recommendationStrategy: pickProperty(properties, ['product_recommendation_strategy', 'recommendation_strategy']),
    doRules: pickProperty(properties, ['do', 'do_rules']),
    doNotRules: pickProperty(properties, ['do_not', 'do_not_rules']),
    upsellCrossSellStrategy: pickProperty(properties, ['upsell_cross_sell_strategy', 'upsell_strategy', 'cross_sell_strategy']),
    exampleResponses: pickProperty(properties, ['example_responses', 'examples']),
    behaviorNotes: pickProperty(properties, ['behavior_notes', 'notes']),
  };
}

Sync behavior from Notion into the app

This is the publish step. It pulls the latest behavior definitions from Notion and stores a published config in the app for fast local runtime use.

export async function syncChatBehaviorFromNotion(shop) {
  const config = await fetchNotionBehaviorDatabase();
  const publishedConfig = normalizePublishedBehaviorConfig({
    ...config,
    source: 'published_notion',
    publishedAt: new Date().toISOString(),
  });

  await updateSettings(shop, {
    behaviorConfigJson: JSON.stringify(publishedConfig),
    behaviorConfigSource: publishedConfig.source,
    behaviorSyncedAt: new Date(),
  });

  behaviorCache = {
    shop,
    config: publishedConfig,
    expiresAt: Date.now() + (Number(process.env.NOTION_BEHAVIOR_CACHE_MS || DEFAULT_CACHE_MS) || DEFAULT_CACHE_MS),
  };

  return publishedConfig;
}

Sync behavior back to Notion

This is the reverse direction. It pushes app-side behavior definitions back into the Notion control panel so the system stays visible and editable.

export async function syncChatBehaviorToNotion(shop) {
  const token = sanitizeText(process.env.NOTION_API_TOKEN);
  const databaseId = sanitizeText(process.env.NOTION_BEHAVIOR_DATABASE_ID);
  if (!token || !databaseId) {
    throw new Error('Missing NOTION_API_TOKEN or NOTION_BEHAVIOR_DATABASE_ID.');
  }

  const settings = shop ? await getSettings(shop) : null;
  const publishedConfig = parseBehaviorConfigJson(settings?.behaviorConfigJson) || cloneDefaultConfig();
  const effectiveConfig = normalizePublishedBehaviorConfig({
    ...publishedConfig,
    source: publishedConfig?.source || 'published',
  });

  const existingRows = await queryNotionBehaviorRows(token, databaseId);
  const rowMap = new Map();

  for (const row of existingRows) {
    const parsed = parseNotionBehaviorRow(row);
    if (!parsed?.key) continue;
    rowMap.set(parsed.key, row.id);
  }

  const intents = Object.entries(effectiveConfig.intents || {});
  for (const [intentKey, behavior] of intents) {
    const mergedBehavior = {
      ...(effectiveConfig.global || {}),
      ...(behavior || {}),
    };

    const properties = buildNotionBehaviorProperties(intentKey, mergedBehavior);
    const existingPageId = rowMap.get(intentKey);

    if (existingPageId) {
      await updateNotionBehaviorPage(token, existingPageId, properties);
    } else {
      await createNotionBehaviorPage(token, databaseId, properties);
    }
  }

  return {
    pushedAt: new Date().toISOString(),
    intentCount: intents.length,
  };
}

Runtime behavior loading in storefront chat

This is where the storefront chat loads the published behavior config for the active shop before generating a response.

const behaviorConfig = await getChatBehaviorConfig(shopDomain || session?.shop || '');

Then later, the response generator uses the active specialist behavior:

async function generateAssistantCopy({
  query,
  items,
  allowFollowups,
  policyAnswer,
  apiKey,
  chatHistory = [],
  behaviorConfig,
  behaviorIntent = 'search'
}) {
  // ... build shortlist and conversation context ...

  const recommendationInstruction =
    behaviorIntent === 'search_training_bags'
      ? trainingBagFollowupInstruction
      : standardFollowupInstruction;

  // behaviorConfig + behaviorIntent shape the final response
}

Admin actions for the two sync buttons

This is the UI-facing action layer that powers Sync From Notion and Sync To Notion.

export const action = async ({ request }) => {
  const { session } = await authenticate.admin(request);
  const formData = await request.formData();
  const actionType = formData.get("action");

  if (actionType === "sync_behavior_to_notion") {
    const result = await syncChatBehaviorToNotion(session.shop);
    return json({
      ok: true,
      behaviorPush: true,
      pushedAt: result?.pushedAt || new Date().toISOString(),
      message: `Behavior pushed to Notion for ${result?.intentCount || 0} intents.`,
    });
  }

  if (actionType === "sync_behavior_from_notion") {
    const config = await syncChatBehaviorFromNotion(session.shop);
    return json({
      ok: true,
      behaviorSync: true,
      source: config?.source || "published_notion",
      syncedAt: config?.publishedAt || config?.fetchedAt || new Date().toISOString(),
      message: "Behavior synced from Notion.",
    });
  }
};

How I Used Notion MCP

Notion MCP is what made this system possible in a meaningful way.

I used Notion MCP to create and operate a behavior database that acts like a control room for the storefront AI.

Each row in Notion represents an intent or a specialized branch, for example:

  • search
  • search_training_bags
  • search_gloves_wraps
  • cart_review
  • orders_selection
  • order_detail

Each row contains structured behavior fields like:

  • Tone Voice
  • Intent Synonyms
  • Intent Instructions
  • Questions To Ask
  • Product Recommendation Strategy
  • Do
  • Do Not
  • Example Responses
  • Behavior Notes

This let me turn Notion into an operator-facing behavior system instead of just a place to store notes.

For example:

  • the training bag specialist can focus on freestanding vs hanging bag decisions
  • the gloves and wraps specialist can focus on MMA gloves, boxing gloves, bag gloves, wraps, padding, and protection
  • the cart review specialist can behave more like a checkout assistant
  • the order specialists can stay concise and service-oriented

The important part is that Notion MCP did not just help me document the system. It helped me build the system that controls the AI behavior.

That unlocked a much stronger workflow:

  • identify a weak response
  • update the specialist behavior in Notion
  • sync it into the app
  • test it live in the storefront

That is why I think this is a strong Notion MCP use case.

I did not just connect Notion to an agent.
I used Notion MCP to build the behavior control plane for an AI commerce brain.

Cart Review Behavior:
cart review behavior

Sale Search Behavior
sale search behavior

The part I like most is that behavior is no longer buried in prompts or scattered across code. Notion became a real operating surface for the AI, and that made the storefront feel less like a chatbot and more like a system of specialists that can actually be tuned, tested, and improved over time.

Smart Crop: Let the API Find Faces, Products, and Key Objects Before Cropping

Cropping Is a Solved Problem — Until It Isn’t

Basic cropping is trivial. Give the coordinates, cut the rectangle, done. The hard part is knowing where to crop.

A product photo has the item off-center. A portrait has the face in the upper third. An editorial image has the focal point at the rule-of-thirds intersection, not the center. Crop any of these with a center-based strategy and you lose the subject.

The standard workaround is manual crop coordinates. Someone looks at each image, decides where to crop, and records the coordinates. This works for 10 images. It breaks down at 10,000.

The Image Transformation API’s smart_crop operation replaces manual coordinates with AI object detection. It finds the main subject — a face, a product, a focal point — and crops around it automatically.

Three Ways to Crop — and When Each Fails

Before diving into smart crop, it helps to understand why simpler approaches fall short.

Manual crop — you specify exact pixel coordinates:

const operations = [
  {
    type: "crop",
    left_in_px: 200,
    top_in_px: 100,
    width: 400,
    height: 400,
  },
];
{
  "success": true,
  "data": {
    "buffer": "/9j/4AAQSkZJRgABAQEASABIAAD...",
    "mime_type": "image/jpeg"
  }
}

This works when you know exactly where the subject is. For user-generated content or bulk image processing, you don’t.

Resize with cover — scales and center-crops to fill the target dimensions:

const operations = [
  {
    type: "resize",
    width: 400,
    height: 400,
    fit: "cover",
  },
];
{
  "success": true,
  "data": {
    "buffer": "/9j/4AAQSkZJRgABAQEASABIAAD...",
    "mime_type": "image/jpeg"
  }
}

This assumes the subject is near the center. It works for well-composed photos but fails for off-center subjects. A person standing on the left side of a landscape shot gets cropped out entirely.

Smart crop — AI finds the subject first, then crops around it:

const operations = [
  {
    type: "smart_crop",
    width: 400,
    height: 400,
  },
];
{
  "success": true,
  "data": {
    "buffer": "/9j/4AAQSkZJRgABAQEASABIAAD...",
    "mime_type": "image/jpeg"
  }
}

No coordinates. No assumption about where the subject is. The API detects it and builds the crop region around it.

How Smart Crop Works

Under the hood, smart crop runs AI object detection on the image before making any crop decisions. It identifies the bounding boxes of faces, people, products, animals, and other objects. Then it calculates the optimal crop region that:

  1. Contains the detected subject(s)
  2. Matches the requested output dimensions
  3. Preserves as much context around the subject as the aspect ratio allows

The result is a crop that’s centered on what matters, not on the geometric center of the image.

You don’t need to tell the API what to look for. You don’t need to specify a focus point. You just provide the target dimensions.

import { IterationLayer } from "iterationlayer";

const client = new IterationLayer({
  apiKey: "YOUR_API_KEY",
});

const result = await client.transform({
  file: {
    type: "url",
    name: "photo.jpg",
    url: "https://cdn.example.com/photos/team-photo.jpg",
  },
  operations: [
    {
      type: "smart_crop",
      width: 600,
      height: 400,
    },
  ],
});
{
  "success": true,
  "data": {
    "buffer": "/9j/4AAQSkZJRgABAQEASABIAAD...",
    "mime_type": "image/jpeg"
  }
}

Use Case: Profile Pictures

Users upload everything as a profile picture — selfies, group photos, full-body shots, photos where they’re at the edge of the frame. A center crop produces usable results maybe 60% of the time. The other 40% cuts off heads, includes the wrong person, or crops to empty background.

Smart crop finds the face.

const operations = [
  {
    type: "smart_crop",
    width: 256,
    height: 256,
  },
  {
    type: "convert",
    format: "webp",
    quality: 85,
  },
];
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

For a group photo, it detects multiple faces and crops to include them. For a full-body shot, it centers on the face rather than the torso. For a photo where the person is at the edge, it shifts the crop region to keep them in frame.

Use Case: Product Thumbnails

E-commerce platforms deal with supplier images that have wildly inconsistent composition. The product might be centered, or it might be in the lower-right corner with 60% of the image as white space. A marketplace listing needs consistent, product-centered thumbnails regardless of the source composition.

const operations = [
  {
    type: "smart_crop",
    width: 500,
    height: 500,
  },
  {
    type: "sharpen",
    sigma: 0.5,
  },
  {
    type: "convert",
    format: "webp",
    quality: 85,
  },
];
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

The AI model detects the product. The crop centers on it. The sharpen pass compensates for any softening from the crop and resize. The result is a consistent, product-centered thumbnail at the exact dimensions the listing needs.

Use Case: Editorial Content Cards

Blog posts, news articles, and content feeds use hero images cropped to card dimensions — typically 16:9 or 4:3. The hero image was composed for full-width display. Cropping it to card dimensions cuts off the top and bottom, which often removes the focal point.

const operations = [
  {
    type: "smart_crop",
    width: 1200,
    height: 630,
  },
  {
    type: "convert",
    format: "webp",
    quality: 85,
  },
];
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

Smart crop finds the visual focal point — the face in a portrait, the building in an architecture photo, the food in a recipe shot — and builds the 1200×630 crop region around it.

Chaining Smart Crop with Other Operations

Smart crop is one operation in a pipeline of up to 30. It fits naturally into a processing chain.

Smart crop, sharpen, convert:

const operations = [
  {
    type: "smart_crop",
    width: 400,
    height: 400,
  },
  {
    type: "sharpen",
    sigma: 0.5,
  },
  {
    type: "convert",
    format: "webp",
    quality: 85,
  },
];
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

Smart crop, auto-contrast, convert:

const operations = [
  {
    type: "smart_crop",
    width: 800,
    height: 600,
  },
  {
    type: "auto_contrast",
  },
  {
    type: "convert",
    format: "jpeg",
    quality: 90,
  },
];
{
  "success": true,
  "data": {
    "buffer": "/9j/4AAQSkZJRgABAQEASABIAAD...",
    "mime_type": "image/jpeg"
  }
}

Smart crop, grayscale, convert:

const operations = [
  {
    type: "smart_crop",
    width: 300,
    height: 300,
  },
  {
    type: "grayscale",
  },
  {
    type: "convert",
    format: "webp",
    quality: 80,
  },
];
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

Operations execute sequentially. The output of smart crop feeds into the next operation. You can combine subject-aware cropping with any of the API’s 24 operations.

Smart Crop vs. Face Detection Libraries

The alternative to smart crop is running your own face/object detection. That means TensorFlow.js or ONNX Runtime in your backend, a model file (50-200 MB), inference time on your CPU or GPU, and the logic to convert bounding boxes into crop coordinates.

Smart crop wraps all of that into a single API parameter. No model to download, no inference to run, no bounding-box-to-crop-coordinate math. The trade-off is clear: you give up fine-grained control over detection thresholds and model selection in exchange for zero infrastructure.

For most thumbnail and content card use cases, the API’s detection is sufficient. The AI model handles common subjects — faces, people, products, animals, vehicles, food — reliably. If you need to detect domain-specific objects that the model doesn’t cover, a custom model is the right approach. For everything else, smart crop saves you the infrastructure.

Batch Smart Cropping

Processing a catalog of images through smart crop follows the standard batch pattern.

import { IterationLayer } from "iterationlayer";

const client = new IterationLayer({
  apiKey: "YOUR_API_KEY",
});

const images = [
  {
    name: "photo-001.jpg",
    url: "https://cdn.example.com/photos/photo-001.jpg",
  },
  {
    name: "photo-002.jpg",
    url: "https://cdn.example.com/photos/photo-002.jpg",
  },
  {
    name: "photo-003.jpg",
    url: "https://cdn.example.com/photos/photo-003.jpg",
  },
];

const thumbnails = await Promise.all(
  images.map(async (image) => {
    const { data: { buffer } } =
      await client.transform({
        file: {
          type: "url",
          name: image.name,
          url: image.url,
        },
        operations: [
          {
            type: "smart_crop",
            width: 400,
            height: 400,
          },
          {
    type: "sharpen",
    sigma: 0.5,
  },
          {
            type: "convert",
            format: "webp",
            quality: 85,
          },
        ],
      });

    return {
      name: image.name,
      buffer: Buffer.from(buffer, "base64"),
    };
  })
);
{
  "success": true,
  "data": {
    "buffer": "UklGRlYAAABXRUJQVlA4IEoA...",
    "mime_type": "image/webp"
  }
}

Every image gets subject-aware cropping, regardless of its original composition. No manual review. No crop coordinate spreadsheets.

What’s Next

Chain with Image Generation to place smart-cropped products on branded card layouts — same auth, same credit pool.

Get Started

Check the docs for the full smart crop reference and all 24 operations you can chain with it. The TypeScript and Python SDKs handle authentication and response parsing.

Sign up for a free account — no credit card required. Upload an image with an off-center subject, run smart crop, and compare it to a center crop.

Upscale, Resize, and Transform Images Inside Claude and Cursor with MCP — No Code Required

Low-Res Images, High-Res Problems

You’re reviewing a pull request and notice the product images are 300×300. The design spec says 1200×1200. The originals are gone — the supplier sent what they sent, and nobody kept higher-resolution versions.

You could download the images, run them through a Python upscaling script, install the dependencies, wait for the model to process, and re-upload. Or you could ask your AI assistant to do it in one sentence.

MCP makes this possible. The Model Context Protocol lets AI assistants like Claude Desktop and Cursor call external APIs as tools. Connect the Image Transformation API as an MCP server, and your assistant can upscale, resize, crop, and convert images directly from a conversation. Describe what you need, it calls the API, you get the result.

No scripts. No dependencies. No context switching.

What MCP Is

Model Context Protocol is an open standard that gives AI assistants the ability to discover and call external services. Think of it as a plugin system — you register a tool, and the assistant uses it when your request matches.

When you connect the Image Transformation API as an MCP server, your assistant gains a full suite of image operations: upscaling low-resolution images at 2x, 3x, or 4x, resizing to exact dimensions, cropping, and converting between formats. All from a single MCP server.

The upscaling capability is not interpolation. The API uses an AI super-resolution model that generates actual detail — texture, edges, fine patterns — that didn’t exist in the original pixels. A blurry 500×500 image at 4x becomes a sharp 2000×2000 image with real visual information.

Setting Up in Claude Code

Claude Code supports MCP servers natively. Add the Iteration Layer server with a single command:

claude mcp add iterationlayer --transport http https://api.iterationlayer.com/mcp

The first time you use an Iteration Layer tool, a browser window opens for OAuth authentication. Log in, authorize access, and you’re connected. No API keys to manage.

To verify the server is available, start a conversation and ask Claude Code what MCP tools it has access to. You should see transform_image listed among the available tools.

Setting Up in Cursor

Add to your .cursor/mcp.json:

{
  "mcpServers": {
    "iterationlayer": {
      "type": "http",
      "url": "https://api.iterationlayer.com/mcp"
    }
  }
}

Save and restart. The Image Transformation tool is now available in your Cursor AI conversations. Authentication works the same way — OAuth in the browser on first use.

Using It Conversationally

Upload an image and describe what you need:

“Upscale this product image to 4x resolution.”

The assistant calls the Image Transformation API with an upscale operation at factor 4, and returns the upscaled image. A 500×500 input becomes 2000×2000 with generated detail — not blurred pixels.

“This logo is too small for the print layout. Upscale it to 2x.”

The assistant uses a 2x factor, doubling the resolution while preserving the logo’s sharpness.

“Upscale this photo to 3x for the hero banner.”

Three times the resolution. The model generates texture and edge detail that makes the image hold up at the larger size.

More MCP Conversations

The conversational pattern scales beyond single-image requests. Here are a few more interactions that show what’s possible.

Comparing factors side by side:

“Upscale this photo at 2x and 4x so I can compare the results.”

The assistant makes two separate API calls — one with factor 2, one with factor 4 — and returns both images. You compare them in the conversation and pick the one that balances file size and quality for your use case.

Upscaling a batch of icons:

“I have these five SVG-exported PNGs at 64×64. Upscale each one to 4x for the app store assets.”

The assistant processes each image individually through the API. Five calls, five results, all in one conversation thread. No script, no loop, no dependencies to install.

Chaining multiple operations:

Because the Image Transformation MCP server handles upscaling, resizing, cropping, and format conversion in a single server, you can chain operations naturally:

“Upscale this image to 3x, then convert it to WebP and resize it to exactly 1200×800.”

The assistant chains the operations through the Image Transformation API — upscale, format conversion, and resize — all in one conversation.

“This supplier photo is 400×300. Upscale it to 4x, then crop it to a square and compress it under 200KB.”

The assistant runs the upscale (producing a 1600×1200 image), then chains a crop and a target-size compression. What would normally be three scripts or a multi-step CLI pipeline becomes one sentence.

When to Upscale

Legacy images. Old product catalogs, archived marketing materials, images from systems that stored thumbnails as the “original.” The high-res version doesn’t exist. AI upscaling is the only way to recover usable resolution.

Supplier-provided images. E-commerce teams deal with this constantly. Suppliers send images at whatever resolution they have — often 300×300 or 400×400. Marketplaces require 1000×1000 minimum. Traditional upscaling produces blurry results. AI upscaling generates the detail needed to meet the spec.

Print preparation. An image looks fine on screen at 72 DPI but falls apart at 300 DPI for print. Upscaling 2x or 4x gives you enough resolution for print-quality output without visible artifacts.

User uploads. Users upload whatever they have — phone screenshots, cropped thumbnails, profile pictures from 2015. If your application needs a larger version, AI upscaling is better than showing a blurry stretched image.

Choosing a Factor

The API supports three upscaling factors. Each one multiplies both dimensions:

  • 2x — a 500×500 image becomes 1000×1000 (4x the pixels). Good for images that are close to the target resolution but need a bump.
  • 3x — a 500×500 image becomes 1500×1500 (9x the pixels). A solid middle ground for most use cases.
  • 4x — a 500×500 image becomes 2000×2000 (16x the pixels). Maximum enhancement. Best for images that are significantly below the target resolution.

Higher factors mean more generated detail. The model is filling in information that wasn’t in the original, so there’s a practical limit — upscaling a 50×50 icon to 4x won’t produce a photo-realistic 200×200 image. For best results, start with the smallest factor that gets you to the resolution you need.

What Happens Under the Hood

When the assistant calls the API, it sends a POST request to https://api.iterationlayer.com/image-transformation/v1/transform with your image and the desired operations. The request body specifies the file and a list of operations — for example, { "file": {"type": "url", "name": "image.jpg", "url": "..."}, "operations": [{"type": "upscale", "factor": 4}] }. The AI model processes the image, generating new pixels based on patterns it learned from millions of high-resolution images.

You can chain multiple operations in a single request — upscale, then resize, then convert to WebP — and the API processes them in sequence. This is what makes the single MCP server so versatile: one connection gives you the full image transformation pipeline.

Quality Expectations

AI upscaling is not magic, and setting expectations correctly matters. The model works best on photographs and natural images — textures, skin, hair, fabric, foliage. These have predictable patterns the model has learned to extend.

It works well on product photography. Supplier images that arrive at 400×400 upscale to 1600×1600 with believable detail. Edges stay sharp, product surfaces gain texture, and the result passes visual inspection at marketplace-required dimensions.

It works less well on text and sharp geometric shapes. A screenshot with small text at 300×200 upscaled to 4x will have smoother text, but it won’t match the crispness of a native-resolution render. For images with prominent text, 2x is usually the practical ceiling before artifacts become noticeable.

Icons and logos fall somewhere in between. Simple shapes with flat colors upscale cleanly. Detailed logos with fine lines may gain subtle softness at higher factors. If you need pixel-perfect scaling for vector-origin artwork, re-exporting from the source SVG at the target resolution is always better — but when the source file is gone, 2x upscaling is a reasonable recovery option.

MCP vs Direct API Calls

MCP is built for ad-hoc work. You need one image transformed, right now, without writing code. The assistant handles the API call, the file encoding, the response parsing. You describe, it delivers.

For production pipelines — upscaling product images during catalog import, processing user uploads on the fly, batch-enhancing legacy image libraries — use the API directly. The request body is JSON. Any language that can make an HTTP POST can transform images.

The two approaches fit different moments. Prototype in conversation with MCP, then move the pattern into your codebase when it becomes a recurring workflow.

Common Pitfalls

Upscaling already-compressed JPEGs. Heavy JPEG compression creates visible block artifacts. Upscaling amplifies those artifacts rather than removing them. If the source image is heavily compressed, the upscaled version will look better than bicubic interpolation, but the compression artifacts will still be visible. Start from the highest-quality source you have.

Using 4x when 2x is enough. Higher factors produce larger files and longer processing times. If you need a 1000×1000 image and your source is 600×600, 2x gives you 1200×1200 — more than enough. Save 4x for cases where the source is genuinely small relative to the target.

Expecting upscaling to fix motion blur or focus issues. AI upscaling generates detail from patterns, but it cannot recover information that was never captured. A blurry photo becomes a larger blurry photo. The model may sharpen some edges, but fundamental focus problems remain.

Get Started

Sign up for a free account at iterationlayer.com — no credit card required. Run the claude mcp add command above, authenticate in the browser, and transform your first image from your next Claude Code or Cursor conversation.

The docs cover the full API reference — request format, response format, file size limits, supported operations, and factor options.

How We Built Our Pitch Deck with Our Own API

Hero slide

The Problem with Slide Decks

We needed marketing slides. The kind you send to potential partners, drop into a pitch deck, or post on social media. Ten slides covering the product, features, integrations, use cases, and a CTA.

The obvious path: open Figma, drag boxes around, export PNGs. Update copy? Back to Figma. New product launched? Back to Figma. Font change? Back to Figma for every slide.

We already had an API that composites images from JSON layers. We already had layout layers that arrange children with gap, alignment, padding, and border radius. The slides were just images with text and colored boxes.

So we built them with our own API.

What We Built

Ten slides, generated from code, pixel-identical every time:

Hero slide

Problem slide

Product slide for Document Extraction

Integrations slide

Recipes slide

CTA slide

Each slide is a single API call. The canvas is 2540×1520 (2x resolution for crisp output on retina screens). The whole deck generates in under 2 seconds.

Layout Layers Are the Key

Most of the slides use layout layers — a layer type that arranges children horizontally or vertically, like CSS flexbox. You set direction, gap, alignment, padding, border radius, and background color. Children auto-size or use fixed dimensions.

This is what makes the slides possible without manual pixel positioning. A trust badge pill is a horizontal layout with an icon and auto-width text:

import { IterationLayer } from "iterationlayer";
const client = new IterationLayer({ apiKey: "YOUR_API_KEY" });

const trustPill = (iconBase64: string, label: string) => ({
  type: "layout" as const,
  direction: "horizontal" as const,
  gap: 8,
  vertical_alignment: "center" as const,
  background_color: "#38bdf8",
  padding_top: 10,
  padding_bottom: 10,
  padding_left: 18,
  padding_right: 22,
  border_radius: 20,
  layers: [
    {
      type: "image" as const,
      file: { type: "base64", name: "icon.svg", base64: iconBase64 },
      dimensions: { width: 18, height: 18 },
    },
    {
      type: "text" as const,
      text: label,
      font_name: "PlusJakartaSans",
      font_size_in_px: 15,
      font_weight: "bold",
      text_color: "#FFFFFF",
      dimensions: { width: "auto", height: 22 },
    },
  ],
});

const result = await client.generateImage({
  dimensions: { width: 2540, height: 1520 },
  layers: [
    { index: 0, type: "solid-color", hex_color: "#0ea5e9" },
    {
      index: 1,
      type: "layout",
      direction: "horizontal",
      gap: 24,
      position: { x: 112, y: 430 },
      layers: [
        trustPill(shieldIconBase64, "Zero data retention"),
        trustPill(flagIconBase64, "Made & hosted in the EU"),
        trustPill(cardIconBase64, "No credit card required"),
      ],
    },
  ],
});

The "auto" width on the text layer is what makes this work. The text renders at its natural width, then the layout wraps it with padding and a background. No measuring, no calculating pixel offsets.

Feature Cards with Background Layers

The product slides show feature cards in a 2×3 grid. Each card is a vertical layout with an icon, title, and description — positioned on the canvas with fixed dimensions:

import { IterationLayer } from "iterationlayer";
const client = new IterationLayer({ apiKey: "YOUR_API_KEY" });

const featureCard = (iconBase64: string, title: string, description: string) => ({
  type: "layout" as const,
  direction: "vertical" as const,
  gap: 32,
  background_color: "#0284c7",
  padding: 56,
  border_radius: 32,
  layers: [
    {
      type: "image" as const,
      file: { type: "base64", name: "icon.svg", base64: iconBase64 },
      dimensions: { width: 56, height: 56 },
    },
    {
      type: "text" as const,
      text: title,
      font_name: "PlusJakartaSans",
      font_size_in_px: 44,
      font_weight: "bold",
      text_color: "#FFFFFF",
      dimensions: { width: 668, height: 68 },
    },
    {
      type: "text" as const,
      text: description,
      font_name: "PlusJakartaSans",
      font_size_in_px: 36,
      text_color: "#e0f2fe",
      dimensions: { width: 668, height: 140 },
    },
  ],
});

const result = await client.generateImage({
  dimensions: { width: 2540, height: 1520 },
  layers: [
    { index: 0, type: "solid-color", hex_color: "#0ea5e9" },
    {
      index: 1,
      ...featureCard(iconBase64, "Schema-Driven Extraction", "Define 17 typed fields and get structured JSON back."),
      dimensions: { width: 780, height: 480 },
      position: { x: 112, y: 480 },
    },
  ],
});

When a card has dimensions set, the layout clips to that size and the border_radius is applied to the fixed container — not the auto-sized content. This means cards in a grid all have identical dimensions regardless of content length.

Centering with Fixed Dimensions

The CTA slide centers the trust badges horizontally. Layout layers don’t center children by default — they stack from the start. But when you set dimensions to the full slide width and horizontal_alignment to "center", the layout positions the auto-sized content in the middle:

import { IterationLayer } from "iterationlayer";
const client = new IterationLayer({ apiKey: "YOUR_API_KEY" });

const result = await client.generateImage({
  dimensions: { width: 2540, height: 1520 },
  layers: [
    { index: 0, type: "solid-color", hex_color: "#0ea5e9" },
    {
      index: 1,
      type: "layout",
      direction: "horizontal",
      horizontal_alignment: "center",
      gap: 24,
      dimensions: { width: 2540, height: 100 },
      position: { x: 0, y: 860 },
      layers: [
        trustPill(shieldBase64, "Zero data retention"),
        trustPill(flagBase64, "Made & hosted in the EU"),
        trustPill(cardBase64, "No credit card required"),
      ],
    },
  ],
});

CTA slide with centered badges

This is the same pattern you’d use for centering a button, a logo, or any group of elements on a slide.

The Generative Wave Background

Every slide shares the same wave background — a deterministic SVG pattern seeded by a fixed string. The algorithm stacks wave bands with varying amplitude and color, rendered as Catmull-Rom spline paths. We covered the wave generator in How We Generate OG Images with Our Own API.

The wave SVG is rendered as a low-opacity image layer on top of a solid sky-blue background. These two layers appear at the bottom of every slide:

import { IterationLayer } from "iterationlayer";
const client = new IterationLayer({ apiKey: "YOUR_API_KEY" });

const waveSvgBase64 = btoa(generateWaveSvg("marketing-slides", 2540, 1520));

const result = await client.generateImage({
  dimensions: { width: 2540, height: 1520 },
  layers: [
    { index: 0, type: "solid-color", hex_color: "#0ea5e9" },
    {
      index: 1,
      type: "image",
      file: { type: "base64", name: "waves.svg", base64: waveSvgBase64 },
      position: { x: 0, y: 0 },
      dimensions: { width: 2540, height: 1520 },
      opacity: 10,
    },
  ],
});

Same input, same output. The slides are byte-identical across runs.

Why Not Figma

A few reasons:

  • Copy changes are code changes. A headline update is a string edit, not a design tool session. It goes through code review like everything else.
  • New products are automatic. When we launched Sheet Generation, the slide appeared in the deck without anyone opening a design tool. The product data already existed — the slide just rendered it.
  • Brand consistency is enforced by code. Every slide uses the same padding, the same font sizes, the same color tokens. There’s no “someone forgot to update the font on slide 7” problem.
  • It’s version controlled. Every slide change is a git commit with a diff you can read.

The tradeoff is that design iteration is slower. You can’t drag a box and see the result instantly. But for slides that follow a template — which most marketing slides do — the code-first approach is faster after the initial setup.

Try It

The Image Generation API docs cover all layer types including layouts. The Generate Product Slide recipe shows a complete slide with pills, feature lists, and a CTA button.

If you’re generating images that follow a pattern — slides, social cards, certificates, tickets — layout layers replace the pixel math with structured composition. Define the structure once, swap the data.

Why 88% of MCP Servers Have No Real Authentication (And How to Fix It)

AI agents are accessing databases, sending emails, calling APIs, and making purchases. But there’s no standard way to identify them, limit what they can do, or trace their actions back to a human.

I dug into the numbers:

  • 88% of MCP servers need authentication
  • Only 8.5% use OAuth
  • 53% rely on static API keys in environment variables
  • 80% of organizations can’t tell what their agents are doing in real-time

This is the wild west. So I built AgentsID to fix it.

The Problem

When you build an MCP server, every tool is wide open by default. Any agent with the API key can call any tool — search, delete, deploy, admin reset — with zero restrictions.

There’s no way to:

  • Give Agent A access to search but block delete
  • Know which agent made which tool call
  • Trace an agent’s actions back to the human who authorized it

The Fix: 3 Lines of Middleware

Install the SDK:

npm install @agentsid/sdk

Add the middleware:

import { createHttpMiddleware } from '@agentsid/sdk'; 

const guard = createHttpMiddleware({
  projectKey: process.env.AGENTSID_PROJECT_KEY,
});

Validate every tool call:

const auth = await guard.validate(token, toolName);
if (!auth.permission.allowed) { 
  return { error: 'Blocked', reason: auth.permission.reason };
}

That’s it. Every tool call is now validated.

What You Can Control

AgentsID uses a deny-first model. Everything is blocked unless you explicitly allow it. The permission engine supports 14 constraint types:

Access — Allow/deny by tool name with wildcards (search_* allowed, delete_* blocked)
Time & Rate — Restrict to business hours, limit calls per minute/hour
Behavioral — Require tools to run in sequence, detect anomalous behavior
Resource — Set budget caps, limit session duration
Governance — Require human approval for sensitive actions, limit delegation depth

Delegation Chains

When Agent A spawns Agent B, permissions automatically narrow. Agent B can never have more access than Agent A. Revoke the parent and the entire chain downstream stops.

Audit Trail

Every tool call — allowed or denied — is logged. You get a full record of what each agent did, when, and why it was allowed or blocked. The dashboard shows it all in a live feed.

Getting Started

npm install @agentsid/sdk    # TypeScript
pip install agentsid          # Python
gem install agentsid          # Ruby

Free tier: 25 agents, 10,000 events/month. No credit card.

Wikipedia bans AI-generated content

The Wikipedia community has officially moved to ban the use of AI-generated content across its platform. As reported on March 27, 2026, this policy shift comes after an extensive debate regarding the risks that large language models pose to the encyclopedia’s core standards of verifiability and neutral point of view. By prioritizing human-led research over automated text, Wikipedia aims to protect its readers from “hallucinations” and ensure that every claim remains grounded in reliable, human-curated sources. This decision marks a significant boundary in the evolution of the internet, reaffirming Wikipedia’s commitment to human oversight in an increasingly automated information landscape.

The primary motivation for this restriction lies in the fundamental incompatibility between generative AI and Wikipedia’s core editorial pillars. LLM frequently produce hallucinations, which are statements that appear factual but are actually fabricated or lack a reliable source (Wikipedia, 2026). These inaccuracies directly violate the “Verifiability” policy, as AI often invents citations or misinterprets complex data. Furthermore, the Wikipedia community determined that the sheer volume of low quality content generated by AI could overwhelm volunteer editors and degrade the reliability of the platform (The Guardian, 2026). Beyond mere errors, there are deep concerns regarding algorithmic bias and the potential for AI to mirror existing societal prejudices, which compromises the “Neutral Point of View” that readers expect.

While the ban is broad, it is not an absolute exile of all automated tools, as the policy provides specific exceptions for linguistic refinement. Editors are still permitted to use AI for copyediting, which includes fixing grammar or typos, as long as the tool does not introduce any new information or citations (Wikipedia, 2026). Additionally, assisted translations between different language versions of the site remain acceptable, provided the human editor is fluent in both languages and manually verifies every sentence. Ultimately, the burden of accuracy rests solely on the individual contributor, because Wikipedia maintains that human editors are legally and editorially responsible for all text they publish (The Guardian, 2026). This ensures that while the process might be faster, the eyes on the page remain human and accountable.

Wikipedia’s decision to restrict AI generated content marks a pivotal moment in the ongoing struggle for digital information quality. In an era where automated “slop” threatens to saturate the internet, this community led stance reaffirms that human curation remains the gold standard for reliable and verified knowledge (The Guardian, 2026). While automation might offer speed, the platform’s commitment to verifiable and neutral information ensures its longevity as a trusted resource (Wikipedia, 2026). Ultimately, this policy highlights that while machines can process data, they cannot replicate the nuanced understanding and moral accountability of a human volunteer.

References

Milman, O. (2026, March 27). Wikipedia bans AI-generated content in its online encyclopedia. The Guardian; The Guardian. https://www.theguardian.com/technology/2026/mar/27/wikipedia-bans-ai

Wikipedia Contributors. (2026, March 27). Wikipedia:Writing articles with large language models. Wikipedia; Wikimedia Foundation.

‌

Why We Built Iteration Layer

Content Processing Is a Mess

If you’ve built anything that touches documents or images, you know the drill. You need to extract data from PDFs, so you duct-tape together an OCR library and a regex parser. You need thumbnails, so you spin up ImageMagick in a Docker container. You need to generate reports or ebooks, so you wrestle with PDF libraries that treat a simple table like a research problem.

Each tool solves one narrow problem. Each one breaks in its own way. And the glue code connecting them — the format conversions, the error handling, the retry logic — that’s where the real complexity lives. Not in the business logic you actually care about.

We’ve been on both sides of this. Before Iteration Layer, we built an AI-driven book publishing company. That meant building the entire content pipeline from scratch: parsing manuscripts, generating book covers programmatically, processing product images for Amazon, rendering marketing graphics for launches. Every piece worked. Every piece also broke in its own creative way at 2 AM before a release.

We maintained the Sharp pipeline that worked perfectly until someone uploaded a CMYK TIFF. We wrote the template engine that couldn’t handle a font weight it hadn’t seen before. We built the manuscript-to-EPUB converter that choked on tables. And every time we fixed one thing, we thought: this should just be an API call.

So we built it.

One Pipeline, Four APIs

Content processing follows a natural lifecycle: you ingest raw content, you transform it, and you generate new output from it. Instead of building one monolithic platform that tries to do everything, we built four focused APIs that map to this lifecycle.

Document Extraction handles ingestion. Give it a PDF, a Word document, a scanned image — it gives you structured JSON. You define a schema describing the fields you want, and the parser extracts them with confidence scores so you know when to trust the result and when to flag it for review. No OCR setup, no template configuration, no regex. It handles field types like text, numbers, dates, addresses, IBANs, currencies, arrays, and more out of the box.

Image Transformation covers the middle of the pipeline — the part where you need to upscale, resize, crop, convert formats, adjust quality, or chain multiple operations together. Define up to 30 operations in a single request: upscale to 4x resolution, resize to 800×600, convert to WebP, compress to 85% quality, smart-crop around the detected subject. One API call instead of a Sharp pipeline you have to host and scale yourself. Need an image under 500 KB for email? Tell the API the target file size and it figures out the optimal quality and dimension tradeoffs.

Image Generation turns templates into pixels. Write your template in HTML and CSS — technologies you already know — inject dynamic data, and get back a PNG, JPEG, or PDF. Social cards, certificates, OG images, report graphics. Anything you’d design in Figma and then manually export fifty times, you can now render programmatically.

Document Generation closes the loop on the output side. Feed it structured data and get back a polished PDF, DOCX, EPUB, or PPTX. Contracts, reports, ebooks, slide decks — generated from templates, populated with your data, ready to ship.

Each API works independently. You can use the Document Extraction without ever touching image processing. You can generate documents without parsing a single one first. But the real leverage comes when you chain them together.

Composable by Design

The output of one API flows naturally into the input of the next. Parse a supplier catalog, feed each product into Image Generation, and you have marketplace-ready listing images — no glue code, no format juggling. Parse manuscripts, transform the cover art, generate the final EPUB. Extract article text, transform the hero image, generate a social card.

This is the Unix philosophy applied to content processing: small, focused tools that compose into workflows you haven’t imagined yet.

That composability is a deliberate choice, and it cuts against the grain. Monolithic platforms lock you into one vendor’s idea of a workflow. If their OCR is good but their image processing is mediocre, tough luck — you’re stuck with both or neither.

We think APIs should work like building blocks. Snap them together however you want. Combine ours with your own services or third-party tools. The output is always standard JSON or image data — nothing proprietary, nothing locked in.

This also means you pay for what you use. Need parsing this month but not image generation? You only pay for parsing. A spike in document parsing doesn’t affect your image generation capacity. Start with one API, add another when the need arises, swap one out without touching the rest.

MCP: APIs as Agent Tools

Every Iteration Layer API ships as an MCP server from day one. If you work with Claude, Cursor, or any MCP-compatible client, our APIs show up as tools your agent can call directly.

That changes the workflow fundamentally. Instead of writing integration code, you describe what you want. “Parse this supplier catalog and generate a product card for each item.” The agent discovers the Document Extraction, calls it with the right schema, takes the structured output, and feeds it into Image Generation — all without you specifying the sequence.

We’ve been using this internally while building the platform, and it’s the kind of thing that feels like a gimmick until you try it. Once you’ve watched an agent chain three API calls to solve a task you were about to spend an hour scripting, it’s hard to go back.

Building Blocks for What’s Next

The four APIs we’re launching cover the core of the content lifecycle, but we see them as the foundation, not the finish line.

Our vision is a library of focused, composable APIs that cover every step of content processing — from raw input to polished output. Each one does one thing well. Each one connects to everything else. The same way Unix pipes let you chain grep | sort | uniq into something no single tool could do, we want you to be able to chain Ingest, Transform, and Generate steps into workflows that solve your specific problem.

That means more APIs, more integrations, more field types, more output formats. But always following the same principles — focused scope, standard I/O, and the ability to snap into any pipeline.

We’re building infrastructure that disappears. You shouldn’t have to think about OCR libraries, image processing servers, or headless browser farms. You should think about your product, your users, and the content workflow that connects them. The plumbing is our problem.

Get Started

Pick the API that solves your most immediate problem — Document Extraction, Image Transformation, Image Generation, or Document Generation. We also publish TypeScript and Python SDKs, so your next npm install or pip install is all the setup you need — and if you’re building with AI agents, the SDKs make our APIs discoverable as tools out of the box.

Sign up for a free account — no credit card required. As new needs come up, add another API to the chain. They all use the same authentication, the same error format, and the same response structure, so adding a second or third takes minutes.

We built this because we needed it. We think you might too.