+ if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label
+ if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value
+ }
+ const result = blocks.ChartBlockSchema.safeParse(parsed)
+ return result.success
+ ? { config: result.data, invalid: false }
+ : { config: null, invalid: true }
+}
+
+/**
+ * Inline chart for chat messages: renders a ```chart fenced block (same
+ * ChartBlockSchema the notes chart block uses) via recharts. Invalid or
+ * still-streaming JSON renders as a quiet placeholder rather than an error —
+ * the fence body arrives token by token while the model writes it.
+ */
+export function ChartRenderer({ source }: ChartRendererProps) {
+ const { resolvedTheme } = useTheme()
+ const colors = resolvedTheme === 'dark' ? SERIES_COLORS_DARK : SERIES_COLORS_LIGHT
+ const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0'
+ const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e'
+
+ const { config, invalid } = useMemo(() => parseChartSource(source), [source])
+
+ // Complete JSON that fails the schema is a real error — say so instead of
+ // showing the streaming placeholder forever.
+ if (invalid || (config && (!config.data || config.data.length === 0))) {
+ return (
+
+
+ {invalid ? 'Chart config invalid — ask me to redraw it' : 'Chart has no data'}
+
+ )
+ }
+ if (!config) {
+ return (
+
+
+ Preparing chart…
+
+ )
+ }
+
+ const data = config.data ?? []
+ const series = blocks.chartSeries(config)
+ const axisProps = {
+ stroke: textColor,
+ tick: { fill: textColor, fontSize: 11 },
+ tickLine: false,
+ }
+
+ return (
+
+ {config.title && (
+
{config.title}
+ )}
+
+ {config.chart === 'line' ? (
+
+
+
+
+
+ {series.length > 1 && }
+ {series.map((key, i) => (
+
+ ))}
+
+ ) : config.chart === 'bar' ? (
+
+
+
+
+
+ {series.length > 1 && }
+ {series.map((key, i) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+ {data.map((_, i) => (
+ |
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/extensions/chart-block.tsx b/apps/x/apps/renderer/src/extensions/chart-block.tsx
index 3377b157..b8454d1c 100644
--- a/apps/x/apps/renderer/src/extensions/chart-block.tsx
+++ b/apps/x/apps/renderer/src/extensions/chart-block.tsx
@@ -64,6 +64,7 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record{error}
if (!data || data.length === 0) return No data
+ const series = blocks.chartSeries(config!)
return (
{config!.chart === 'line' ? (
@@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record
-
+ {series.map((key, index) => (
+
+ ))}
) : config!.chart === 'bar' ? (
@@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record
-
+ {series.map((key, index) => (
+
+ ))}
) : (
-
+
{data.map((_, index) => (
|
))}
diff --git a/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts
new file mode 100644
index 00000000..71ac3110
--- /dev/null
+++ b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts
@@ -0,0 +1,97 @@
+export const skill = String.raw`
+# Charts
+
+Load this skill when the user asks for a chart, graph, plot, trend, comparison, or "visualize" — or when data you've gathered (prices over time, counts per category, proportions) would land better as a picture than a table.
+
+## How it works
+
+Emit a fenced code block with language \`chart\` anywhere in your reply. The app renders it as an interactive chart inline (tooltips, legend, light/dark theming are handled for you — never pick colors yourself). Everything outside the fence is normal markdown.
+
+\`\`\`\`
+\`\`\`chart
+{ ...JSON config... }
+\`\`\`
+\`\`\`\`
+
+## Config schema
+
+The ONLY fields are \`chart\`, \`data\`, \`x\`, \`y\`, \`title\` — nothing else exists (no \`label\`, \`value\`, \`series\`, \`color\`, etc.). Unknown fields are ignored; a missing \`x\` or \`y\` breaks the chart.
+
+- **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\`
+- **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers.
+- **\`x\`** (required): key of the label/category field in each row. For pie: the slice-name key.
+- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart. For pie: the slice-value key.
+- **\`title\`** (optional): short heading shown above the chart
+
+**Data must be wide-format**: one row per x value, one key per series. Never long/tidy format (a row per series-point with a series-name column) — multiple series means multiple keys in the SAME row:
+
+WRONG: \`[{ "day": "Mon", "index": "S&P", "pct": 0.1 }, { "day": "Mon", "index": "Dow", "pct": 0.2 }]\`
+RIGHT: \`[{ "day": "Mon", "S&P": 0.1, "Dow": 0.2 }]\` with \`"y": ["S&P", "Dow"]\`
+
+## Picking the form
+
+- **line** — change over time (prices, counts by date). X is the time field.
+- **bar** — compare magnitudes across categories (issues per label, revenue per region).
+- **pie** — proportions of a whole; only with ≤ 6 slices, otherwise use a bar.
+- Two measures with very different scales (e.g. a $600 stock vs a $5 one): do NOT mix them on one chart — either normalize to % change and say so in the title, or emit two chart blocks.
+
+## Examples
+
+Multi-series line (comparison over time, normalized):
+
+\`\`\`chart
+{
+ "chart": "line",
+ "title": "5-Day % Change",
+ "x": "date",
+ "y": ["AAPL", "NVDA", "SPY"],
+ "data": [
+ { "date": "Jul 15", "AAPL": 0, "NVDA": 0, "SPY": 0 },
+ { "date": "Jul 16", "AAPL": -0.4, "NVDA": 1.2, "SPY": 0.1 },
+ { "date": "Jul 17", "AAPL": -1.1, "NVDA": 0.8, "SPY": -0.3 }
+ ]
+}
+\`\`\`
+
+Single-series bar:
+
+\`\`\`chart
+{
+ "chart": "bar",
+ "title": "Open issues by area",
+ "x": "area",
+ "y": "count",
+ "data": [
+ { "area": "sync", "count": 14 },
+ { "area": "editor", "count": 9 },
+ { "area": "billing", "count": 3 }
+ ]
+}
+\`\`\`
+
+Pie (x names the slice, y is its value — same keys as every other chart):
+
+\`\`\`chart
+{
+ "chart": "pie",
+ "title": "Time spent by project",
+ "x": "project",
+ "y": "hours",
+ "data": [
+ { "project": "Alpha", "hours": 14 },
+ { "project": "Beta", "hours": 6 },
+ { "project": "Internal", "hours": 3 }
+ ]
+}
+\`\`\`
+
+## Rules
+
+- Data must come from what you actually fetched or computed this turn — never fabricate points to make a chart possible. Too little real data? Say so instead of charting.
+- Keep it readable: ≤ ~30 rows per chart, ≤ 6 series. Round values sensibly.
+- Numbers must be JSON numbers, not strings ("3.1", "5%" won't plot).
+- Follow the chart with one sentence of takeaway — what the reader should see in it.
+- One chart per distinct question; don't emit several variants of the same data.
+`;
+
+export default skill;
diff --git a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts
index 27c5dea5..9678df7d 100644
--- a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts
+++ b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts
@@ -224,7 +224,7 @@ Renders a chart from inline data.
- \`data\` (optional): Array of objects with the data points
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
- \`x\` (required): Key name for the x-axis / label field
-- \`y\` (required): Key name for the y-axis / value field
+- \`y\` (required): Key name for the y-axis / value field — or an array of key names to plot several series on one chart (each row then carries one key per series)
### Table Block
Renders a styled table from structured data.
diff --git a/apps/x/packages/core/src/runtime/assembly/skills/index.ts b/apps/x/packages/core/src/runtime/assembly/skills/index.ts
index ee676fa1..1124ae93 100644
--- a/apps/x/packages/core/src/runtime/assembly/skills/index.ts
+++ b/apps/x/packages/core/src/runtime/assembly/skills/index.ts
@@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
import appsSkill from "./apps/skill.js";
import slackSkill from "./slack/skill.js";
+import chartsSkill from "./charts/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/runtime/assembly/skills";
@@ -77,6 +78,12 @@ const definitions: SkillDefinition[] = [
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
+ {
+ id: "charts",
+ title: "Charts",
+ summary: "Render interactive charts (line, bar, pie) inline in the chat reply. Use when the user asks for a chart/graph/visualization or when gathered data is clearer as a picture.",
+ content: chartsSkill,
+ },
{
id: "organize-files",
title: "Organize Files",
diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts
index 6e6c75c2..f1ee90a7 100644
--- a/apps/x/packages/shared/src/blocks.ts
+++ b/apps/x/packages/shared/src/blocks.ts
@@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({
data: z.array(z.record(z.string(), z.unknown())).optional(),
source: z.string().optional(),
x: z.string(),
- y: z.string(),
+ // One series (string) or several (array of data keys). Pie ignores all
+ // but the first.
+ y: z.union([z.string(), z.array(z.string()).min(1)]),
});
export type ChartBlock = z.infer;
+/** The y series list regardless of which form the block used. */
+export function chartSeries(block: ChartBlock): string[] {
+ return Array.isArray(block.y) ? block.y : [block.y];
+}
+
export const TableBlockSchema = z.object({
columns: z.array(z.string()),
data: z.array(z.record(z.string(), z.unknown())),