Search...Search plugins and themes...
⌘K
Sign in
  • Get started
  • Download
  • Pricing
  • Enterprise
  • Account
  • Obsidian
  • Overview
  • Sync
  • Publish
  • Canvas
  • Mobile
  • Web Clipper
  • CLI
  • Learn
  • Help
  • Developers
  • Changelog
  • About
  • Roadmap
  • Blog
  • Resources
  • System status
  • License overview
  • Terms of service
  • Privacy policy
  • Security
  • Community
  • Plugins
  • Themes
  • Discord
  • Forum / 中文论坛
  • Merch store
  • Brand guidelines
Follow us
DiscordTwitterBlueskyThreadsMastodonYouTubeGitHub
© 2026 Obsidian

Chart Hands

everydaymindeverydaymind153 downloads

Render Plotly charts from code blocks and give LLM agents tools to write and check them.

Add to Obsidian
  • Overview
  • Scorecard
  • Updates14

Chart Hands draws interactive Plotly charts in your Obsidian notes from short plotly code blocks, with no JavaScript needed. You can hover over a chart to read values and drag to zoom. Charts fit the note width, and they fit the page when you export a note to PDF.

Chart Hands also gives LLM agents a pair of "hands": tools that build and check chart blocks before an agent writes them into a note. You don't need an agent to use the plugin.

It is a fork of obsidian-plotly by Dmitriy Shulha (MIT), updated to Plotly.js 4 and D3 7. It adds width fitting, PDF export support and the agent tools. It works on desktop and mobile.

Contents: Quick start · Writing a plotly block · Using examples from plotly.com · Charts from vault data (DataviewJS) · When a chart doesn't show · Settings · Examples · Tools for LLM agents · Installation

Quick start

  1. Install and enable Chart Hands (see Installation).

  2. Open a note, press Ctrl+P (Cmd+P on macOS) and run Chart Hands: New Plotly chart, or paste this block:

    ```plotly
    data:
      - x: [0, 1, 2]
        y: [0, 1, 0]
    ```
    
  3. Move the cursor out of the block in Live Preview, or switch to Reading view, and a line chart appears. The first chart can take a moment, because Plotly loads the first time a chart is drawn. A "loading Plotly and D3…" notice shows while that happens.

Next, try a bar chart with a title and axis labels:

```plotly
data:
  - type: bar
    x: ["Mon", "Tue", "Wed", "Thu", "Fri"]
    y: [3, 5, 2, 6, 4]
layout:
  title: {text: "Hours of focus"}
  xaxis: {title: {text: "Day"}}
  yaxis: {title: {text: "Hours"}}
```

Change type: bar to type: scatter to draw a line instead. When that works, copy any chart from Examples and replace the numbers with your own.

Writing a plotly block

The block is YAML or JSON (both are read the same way) with up to four top-level keys:

Key What it holds
data A list of traces. Each trace is one series: its type (bar, scatter, pie, heatmap, …) and its values (x, y, labels, values, …). Without a type, a trace is drawn as scatter.
layout Optional. The title, axes, legend, size, margins, reference lines (shapes) and notes (annotations).
config Optional. Interaction options such as displaylogo: false, displayModeBar: false or scrollZoom: true.
fit Optional. off, shrink or fill, to override the Fit chart width setting for this chart only.

These are the objects that Plotly.js takes in Plotly.newPlot(div, data, layout, config). Every option is listed in the Plotly.js reference.

A few YAML rules cause most of the problems:

  • Quote colours: write color: "#2563EB". Without quotes, # starts a YAML comment and the colour is lost.
  • Quote dates: write x: ["2026-01-05", "2026-01-12"], so the values stay as text and Plotly reads them as dates.
  • Write titles as objects: write title: {text: "Sales"}. Plotly.js 4 doesn't show a title written as a plain string.
  • Indent with spaces, never tabs, and start each trace with - .
  • No JavaScript: a plotly block holds values only, so functions, variables and new Date() don't work. For computed data, use DataviewJS.

Using examples from plotly.com

The Plotly.js examples are written in JavaScript. When an example only builds objects from plain values, copy those objects into a plotly block. This example:

var trace1 = {x: [1, 2, 3, 4], y: [10, 15, 13, 17], type: 'scatter'};
var trace2 = {x: [1, 2, 3, 4], y: [16, 5, 11, 9], type: 'scatter'};
var layout = {title: {text: 'Two lines'}};
Plotly.newPlot('myDiv', [trace1, trace2], layout);

becomes:

```plotly
data:
  - {x: [1, 2, 3, 4], y: [10, 15, 13, 17], type: scatter}
  - {x: [1, 2, 3, 4], y: [16, 5, 11, 9], type: scatter}
layout:
  title: {text: "Two lines"}
```

If the example computes its values (loops, Math.random(), d3.csv(...)), run it in a DataviewJS block instead.

Charts from vault data (DataviewJS)

For a chart computed from your notes, or a plotly.com example that runs code, use a dataviewjs block. This needs the Dataview plugin with Enable JavaScript queries turned on in its settings.

Run Chart Hands: New Plotly chart (Dataviewjs) to insert a starter block. When you paste a plotly.com example, replace Plotly.newPlot with window.renderPlotly and draw into this.container:

// plotly.com
Plotly.newPlot('myDiv', data, layout, config);
// DataviewJS in Obsidian
window.renderPlotly(this.container, data, layout, config);

This block counts the notes created in each month:

```dataviewjs
const counts = {};
for (const page of dv.pages()) {
  const month = page.file.ctime.toFormat('yyyy-MM');
  counts[month] = (counts[month] || 0) + 1;
}
const months = Object.keys(counts).sort();
window.renderPlotly(this.container,
  [{type: 'bar', x: months, y: months.map(m => counts[m]), marker: {color: '#2563EB'}}],
  {title: {text: 'Notes created per month'}});
```

window.renderPlotly waits for Plotly to load by itself. An optional fifth argument ('off', 'shrink' or 'fill') sets the width fitting for that chart. Plotly.js and D3 are bundled, so unlike obsidian-plotly you don't need to download D3 into your vault. Code that calls Plotly or d3 directly must load them first:

const { Plotly, d3 } = await window.loadPlotlyLibraries();

When a chart doesn't show

What you see What to check
The block stays as code The plugin is enabled, and the fence language is exactly plotly in lowercase. In Live Preview, move the cursor outside the block.
Couldn't render plot: followed by a YAML error Indentation (spaces, not tabs), a missing - before a trace, or an unclosed quote or bracket. The message shows the line.
The only valid keys are data, layout, config and fit. A key such as title or type is at the top level. Move it under layout or into a trace.
There should be a valid JSON in this block. The block is empty, or it holds only comments.
The chart is drawn but a colour or title is missing Quote # colours, and write titles as title: {text: "..."}.
A "could not load Plotly" notice Reopen the note. Chart Hands tries to load Plotly again the next time a chart is drawn.
A DataviewJS block draws nothing Dataview is installed with JavaScript queries enabled, and the code calls window.renderPlotly(this.container, ...) rather than Plotly.newPlot.

Settings

Open Settings → Chart Hands:

  • Fit chart width: Shrink only when wider (the default) narrows charts that are wider than the note. Always fill the width stretches every chart to the note width. Off keeps the original size (layout.width, or Plotly's 700 px). To change one chart, add fit: off, fit: shrink or fit: fill to its block.
  • Keep aspect ratio when shrinking (on by default): scales the height along with the width.
  • Fit the page width when exporting to PDF (on by default): shrinks charts that are wider than the page, so they aren't cut off when you export to PDF or print.

Examples

Copy any block below into a note. All of them are YAML except the last, which shows the same format in JSON. The colours come from the Chart Hands palette, so you only need to replace the sample numbers and labels with your own.

To show Example
A trend over time Line chart with a target line, Time series with a range slider
A comparison between categories Grouped bars, Stacked bars, Bars and a line on a secondary axis
Parts of a whole Donut chart, Sunburst
How values are spread Box plots
A relationship between measures Bubble chart
A grid of values Heatmap habit tracker
Flows and running totals Sankey diagram, Waterfall
Key numbers KPI row with a gauge
Items rated on several criteria Radar chart
Switching views Buttons that switch views
The JSON format JSON and the fit key

Line chart with a target line

Two series, a dashed line for last year, and a reference line drawn with shapes.

```plotly
data:
  - type: scatter
    mode: lines+markers
    name: "2025"
    x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
    y: [32, 35, 31, 40, 43, 47]
    line: {color: "#64748B", dash: dash}
  - type: scatter
    mode: lines+markers
    name: "2026"
    x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
    y: [38, 41, 44, 46, 52, 55]
    line: {color: "#2563EB", width: 3}
layout:
  title: {text: "Monthly active users"}
  hovermode: "x unified"
  yaxis: {title: {text: "Users (thousands)"}, rangemode: tozero}
  shapes:
    - {type: line, xref: paper, x0: 0, x1: 1, y0: 50, y1: 50, line: {color: "#C2410C", dash: dot}}
  annotations:
    - {xref: paper, x: 0, y: 50, text: "Target", showarrow: false, xanchor: left, yanchor: bottom}
config:
  responsive: true
  displaylogo: false
```

Grouped bars with value labels

```plotly
data:
  - {type: bar, name: "Plan", x: ["Q1", "Q2", "Q3", "Q4"], y: [120, 140, 160, 180], marker: {color: "#64748B"}}
  - type: bar
    name: "Actual"
    x: ["Q1", "Q2", "Q3", "Q4"]
    y: [115, 152, 171, 168]
    marker: {color: "#2563EB"}
    texttemplate: "%{y}"
    textposition: outside
    cliponaxis: false
layout:
  title: {text: "Plan vs actual"}
  barmode: group
  yaxis: {title: {text: "Amount (M KRW)"}}
```

Stacked bars

barmode: stack piles the categories of each month on top of each other.

```plotly
data:
  - {type: bar, name: "Housing", x: ["Jul", "Aug", "Sep", "Oct"], y: [900, 900, 900, 900], marker: {color: "#2563EB"}}
  - {type: bar, name: "Food", x: ["Jul", "Aug", "Sep", "Oct"], y: [420, 465, 390, 440], marker: {color: "#0F766E"}}
  - {type: bar, name: "Transport", x: ["Jul", "Aug", "Sep", "Oct"], y: [150, 180, 140, 160], marker: {color: "#7C3AED"}}
  - {type: bar, name: "Other", x: ["Jul", "Aug", "Sep", "Oct"], y: [230, 310, 205, 260], marker: {color: "#64748B"}}
layout:
  title: {text: "Monthly spending by category"}
  barmode: stack
  yaxis: {title: {text: "Amount (1,000 KRW)"}}
```

Bars and a line on a secondary axis

Use a second y axis only when the two series have different units.

```plotly
data:
  - {type: bar, name: "Orders", x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], y: [320, 340, 390, 410, 460, 480], marker: {color: "#2563EB"}}
  - type: scatter
    mode: lines+markers
    name: "Return rate"
    x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
    y: [4.2, 3.9, 4.5, 3.6, 3.1, 2.8]
    yaxis: y2
    line: {color: "#C2410C"}
layout:
  title: {text: "Orders and return rate"}
  yaxis: {title: {text: "Orders"}}
  yaxis2: {title: {text: "Return rate (%)"}, overlaying: "y", side: right, showgrid: false, tickmode: auto, rangemode: tozero}
  legend: {orientation: "h", x: 0, y: -0.15}
```

Donut chart

```plotly
data:
  - type: pie
    hole: 0.55
    labels: ["Deep work", "Meetings", "Email", "Admin", "Learning"]
    values: [18, 9, 6, 4, 3]
    sort: false
    textinfo: "label+percent"
    marker:
      colors: ["#2563EB", "#C2410C", "#64748B", "#7C3AED", "#0F766E"]
      line: {color: "#FFFFFF", width: 2}
layout:
  title: {text: "Where the week went"}
  showlegend: false
  annotations:
    - {text: "40 h", showarrow: false, font: {size: 22}, xref: paper, yref: paper, x: 0.5, y: 0.5}
```

Bubble chart

Marker size carries a third measure; hovertemplate shows all three.

```plotly
data:
  - type: scatter
    mode: markers+text
    x: [12, 25, 8, 30, 18]
    y: [4.1, 3.6, 4.6, 3.2, 4.3]
    text: ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]
    customdata: [120, 340, 80, 510, 220]
    textposition: "top center"
    marker: {size: [16, 30, 12, 40, 22], color: "#2563EB", opacity: 0.6, line: {color: "#FFFFFF", width: 1}}
    hovertemplate: "%{text}<br>Price %{x} USD<br>Rating %{y}<br>Sold %{customdata}<extra></extra>"
layout:
  title: {text: "Price, rating and units sold"}
  xaxis: {title: {text: "Price (USD)"}}
  yaxis: {title: {text: "Rating"}}
```

Box plots

Compare distributions; boxpoints: all also shows every value.

```plotly
data:
  - {type: box, name: "Weekdays", y: [6.1, 6.5, 6.8, 7.0, 6.2, 5.9, 6.6, 7.1, 6.4, 6.0], boxmean: true, boxpoints: all, jitter: 0.3, marker: {color: "#2563EB"}}
  - {type: box, name: "Weekends", y: [7.5, 8.2, 7.8, 8.6, 9.0, 7.2], boxmean: true, boxpoints: all, jitter: 0.3, marker: {color: "#0F766E"}}
layout:
  title: {text: "Hours of sleep"}
  yaxis: {title: {text: "Hours"}}
  showlegend: false
```

Heatmap habit tracker

z is a list of rows, one per y value. autorange: reversed keeps week 1 on top.

```plotly
data:
  - type: heatmap
    x: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
    y: ["Week 1", "Week 2", "Week 3", "Week 4"]
    z:
      - [30, 45, 0, 60, 20, 90, 0]
      - [40, 30, 50, 0, 35, 120, 60]
      - [0, 55, 45, 50, 40, 0, 75]
      - [60, 50, 40, 70, 0, 100, 90]
    colorscale: Blues
    texttemplate: "%{z}"
    xgap: 3
    ygap: 3
    colorbar: {title: {text: "Minutes"}}
layout:
  title: {text: "Reading minutes per day"}
  yaxis: {autorange: reversed}
```

Sunburst

parents links each label to its parent ("" for the root). With branchvalues: total each parent's value is the sum of its children. Change type to treemap or icicle to draw the same data another way.

```plotly
data:
  - type: sunburst
    labels: ["Projects", "Work", "Personal", "Report", "Website", "Garden", "Language"]
    parents: ["", "Projects", "Projects", "Work", "Work", "Personal", "Personal"]
    values: [60, 40, 20, 24, 16, 8, 12]
    branchvalues: total
    textinfo: "label+value"
layout:
  title: {text: "Hours by project this month"}
  margin: {l: 10, r: 10, t: 60, b: 10}
```

Sankey diagram

link.source and link.target are 0-based indexes into node.label.

```plotly
data:
  - type: sankey
    node:
      label: ["Salary", "Side income", "Budget", "Housing", "Living", "Savings", "Investing"]
      color: ["#2563EB", "#7C3AED", "#64748B", "#C2410C", "#C2410C", "#0F766E", "#0F766E"]
      pad: 18
      thickness: 16
    link:
      source: [0, 1, 2, 2, 2, 2]
      target: [2, 2, 3, 4, 5, 6]
      value: [3000, 500, 1000, 1200, 800, 500]
      color: "rgba(100, 116, 139, 0.25)"
layout:
  title: {text: "Where the money goes (1,000 KRW)"}
```

Waterfall

measure marks each step as absolute (a starting level), relative (a change) or total (the running total; its y is ignored).

```plotly
data:
  - type: waterfall
    x: ["Opening", "Salary", "Rent", "Living", "Savings", "Closing"]
    measure: [absolute, relative, relative, relative, relative, total]
    y: [1200, 3000, -1000, -1200, -800, 0]
    texttemplate: "%{delta:,.0f}"
    textposition: outside
    increasing: {marker: {color: "#0F766E"}}
    decreasing: {marker: {color: "#C2410C"}}
    totals: {marker: {color: "#2563EB"}}
    connector: {line: {color: "#64748B", dash: dot}}
layout:
  title: {text: "Cash this month (1,000 KRW)"}
  showlegend: false
```

Time series with a range slider and an event band

Quote the dates so they stay strings. The grey band marks a period; the buttons above the chart zoom to the last month or three.

```plotly
data:
  - type: scatter
    mode: lines+markers
    name: "Distance"
    x: ["2026-01-05", "2026-01-12", "2026-01-19", "2026-01-26", "2026-02-02", "2026-02-09", "2026-02-16", "2026-02-23", "2026-03-02", "2026-03-09", "2026-03-16", "2026-03-23"]
    y: [18, 22, 25, 21, 27, 8, 5, 12, 20, 26, 30, 28]
    line: {color: "#2563EB", width: 2}
layout:
  title: {text: "Weekly running distance"}
  xaxis:
    rangeslider: {visible: true}
    rangeselector:
      buttons:
        - {count: 1, label: "1M", step: month, stepmode: backward}
        - {count: 3, label: "3M", step: month, stepmode: backward}
        - {step: all, label: "All"}
  yaxis: {title: {text: "km"}}
  shapes:
    - {type: rect, xref: x, yref: paper, x0: "2026-02-06", x1: "2026-02-20", y0: 0, y1: 1, fillcolor: "rgba(100, 116, 139, 0.15)", line: {width: 0}, layer: below}
  annotations:
    - {xref: x, yref: paper, x: "2026-02-13", y: 1, text: "Knee injury", showarrow: false, yanchor: bottom}
```

KPI row with a gauge

Indicators are placed in a grid with domain.

```plotly
data:
  - type: indicator
    mode: number+delta
    value: 1284
    title: {text: "Visitors"}
    delta: {reference: 1130}
    domain: {row: 0, column: 0}
  - type: indicator
    mode: number+delta
    value: 0.042
    number: {valueformat: ".1%"}
    title: {text: "Conversion"}
    delta: {reference: 0.038, valueformat: ".1%"}
    domain: {row: 0, column: 1}
  - type: indicator
    mode: gauge+number
    value: 87
    title: {text: "Satisfaction"}
    gauge:
      axis: {range: [0, 100]}
      bar: {color: "#2563EB"}
      threshold: {value: 90, line: {color: "#C2410C", width: 3}, thickness: 0.8}
    domain: {row: 0, column: 2}
layout:
  grid: {rows: 1, columns: 3, pattern: independent}
  height: 260
  margin: {l: 30, r: 30, t: 50, b: 20}
```

Radar chart

Repeat the first point at the end to close each outline.

```plotly
data:
  - type: scatterpolar
    name: "Laptop A"
    r: [4, 3, 5, 4, 2, 4]
    theta: ["Speed", "Battery", "Screen", "Keyboard", "Weight", "Speed"]
    fill: toself
    line: {color: "#2563EB"}
  - type: scatterpolar
    name: "Laptop B"
    r: [3, 5, 3, 4, 4, 3]
    theta: ["Speed", "Battery", "Screen", "Keyboard", "Weight", "Speed"]
    fill: toself
    line: {color: "#C2410C", dash: dash}
layout:
  title: {text: "Laptop comparison (1 to 5)"}
  polar: {radialaxis: {range: [0, 5]}}
```

Buttons that switch views

updatemenus change the chart without code: method: restyle shows or hides traces, method: relayout changes the layout.

```plotly
data:
  - {type: bar, name: "2025", x: ["North", "South", "East", "West"], y: [380, 290, 340, 300], marker: {color: "#64748B"}}
  - {type: bar, name: "2026", x: ["North", "South", "East", "West"], y: [420, 310, 380, 290], marker: {color: "#2563EB"}}
layout:
  title: {text: "Revenue by region (M KRW)", x: 0, xanchor: left}
  margin: {t: 100}
  updatemenus:
    - type: buttons
      direction: right
      x: 0
      y: 1.12
      xanchor: left
      yanchor: bottom
      buttons:
        - {label: "Both", method: restyle, args: [{visible: [true, true]}]}
        - {label: "2025", method: restyle, args: [{visible: [true, false]}]}
        - {label: "2026", method: restyle, args: [{visible: [false, true]}]}
    - type: buttons
      direction: right
      x: 1
      y: 1.12
      xanchor: right
      yanchor: bottom
      buttons:
        - {label: "Grouped", method: relayout, args: [{barmode: group}]}
        - {label: "Stacked", method: relayout, args: [{barmode: stack}]}
```

JSON and the fit key

The block can also be JSON. fit: fill stretches this chart to the note width, whatever the plugin setting.

```plotly
{
  "data": [{"type": "bar", "x": ["A", "B", "C"], "y": [3, 5, 2], "marker": {"color": "#2563EB"}}],
  "layout": {"title": {"text": "Written in JSON"}},
  "fit": "fill"
}
```

Exploring a CSV yourself (no LLM needed)

The CSV exploration tools (eda_profile and eda_analyze, described under CSV exploration for agents) are ordinary functions, so you can call them from a dataviewjs block and draw the results with Chart Hands. This needs Dataview with Enable JavaScript queries turned on, the same as Charts from vault data. The tools only read the CSV; they never change it.

Put this helper at the top of a dataviewjs block. data/survey.csv is a vault-relative path; replace it with your own file:

```dataviewjs
const provider = app.plugins.plugins['chart-hands'][Symbol.for('dev.agentinterop.capability-provider')];
const eda = async (name, args) => (await provider.callTool(name, args)).structuredContent;
const path = 'data/survey.csv';
```

The examples below each continue in the same block, below those three lines.

1. Check the data first (profile)

Which columns are numbers, how many cells are empty, which values repeat? eda_profile returns five columns per call, so loop with nextFrom to see them all:

const rows = [];
let from = 1;
while (from) {
  const p = await eda('eda_profile', { path, from });
  for (const c of p.columns) {
    rows.push([c.name, c.type, `${(c.missingRate * 100).toFixed(1)}%`, c.distinct,
      c.statistics ? `${c.statistics.min} to ${c.statistics.max}` : c.top.map(t => t.value).join(', ')]);
  }
  from = p.nextFrom;
}
dv.table(['Column', 'Type', 'Missing', 'Distinct', 'Range or top values'], rows);

Look for these first: a text column that should be numeric (one stray value such as N/A or 1,200 turns the whole column into text), a high missing rate, and a distinct count of 1 (a column that never varies).

2. Distribution of one column (histogram)

const h = await eda('eda_analyze', { path, analysis: { type: 'histogram', columns: ['score'], bins: 10 } });
window.renderPlotly(this.container,
  [{ type: 'bar', x: h.bins.map(b => `${b.lower.toFixed(1)}–${b.upper.toFixed(1)}`), y: h.bins.map(b => b.count), marker: { color: '#2563EB' } }],
  { title: { text: `score (${h.validRows} rows, ${h.excludedRows} excluded)` }, bargap: 0.05 });

A single tall bar suggests an outlier stretching the range; two humps suggest two groups mixed together. Always read excludedRows: it counts blank and nonnumeric cells that were left out.

3. Do two columns move together? (correlation)

const pairs = [['duration', 'score'], ['duration', 'age'], ['score', 'age']];
const out = [];
for (const [a, b] of pairs) {
  const r = await eda('eda_analyze', { path, analysis: { type: 'correlation', columns: [a, b] } });
  out.push([a, b, r.correlation === null ? 'n/a' : r.correlation.toFixed(2), r.validPairs]);
}
dv.table(['A', 'B', 'Pearson r', 'Pairs used'], out);

r runs from -1 to 1; near 0 means no straight-line relationship. It says nothing about cause, and it does not detect curved relationships, so draw a scatter chart of any pair that matters before you conclude anything. n/a means fewer than two usable pairs, or one column never varies.

4. A quick summary of one column

const s = (await eda('eda_analyze', { path, analysis: { type: 'summary', columns: ['score'] } })).statistics;
dv.paragraph(`n=${s.count}, mean ${s.mean?.toFixed(2)}, median ${s.median}, sd ${s.stddevSample?.toFixed(2)}, range ${s.min} to ${s.max}`);

When the mean and the median differ a lot, the data is skewed or has outliers.

A typical session

  1. Save the CSV in your vault, for example data/survey.csv, with a header row of unique column names.
  2. Run the profile (example 1). Fix problems in the CSV: fill or delete blank cells, remove units and thousands separators from number columns.
  3. Draw a histogram for each column you care about (example 2).
  4. Check correlations between pairs of numeric columns (example 3).
  5. Turn what you found into a chart for your note. Use the plotly block from Examples, or pass the same numbers to window.renderPlotly.

Limits

  • A CSV needs a header, at most 100 columns and 100,000 data rows, and at most 5 MiB. Fields are separated by commas only; quoted fields and newlines inside them are supported.
  • A column counts as numeric only when every nonblank cell is a plain number (12, -3.5, 1e3). Values such as 1,200, $5, 50% or N/A make it text.
  • Only Pearson correlation, summary and histogram (1 to 50 bins) are available. For anything else, use the profile results to decide what to compute in your own DataviewJS.
  • This calls the plugin's agent interface directly. It is not a separate commands-palette feature, and its app.plugins.plugins['chart-hands'] lookup assumes the plugin folder is named chart-hands.

Tools for LLM agents

You can skip this section if you don't use an AI agent in Obsidian.

Chart Hands exposes an agentinterop v1 capability provider on the plugin instance. A compatible agent host can read the tool definitions and call them, once you approve them in the host:

  • get_chart_guide: returns writing guidance by topic, so the agent reads only what it needs. basics maps the question a chart answers (trend, comparison, share, distribution, relationship, matrix, hierarchy, flow, stages, profile, KPI) to a chart type; statistical, hierarchy, timeseries, scientific, maps and layout give checked examples of Plotly's advanced charts (box and violin, correlation heatmaps, splom, treemap and sunburst, sankey, waterfall, candlestick with range buttons, gauges, contour and 3D, polar, ternary, parallel coordinates, maps, subplots, buttons and sliders). style, examples, dataviewjs and errors cover the look, basic examples, DataviewJS and fixes.

  • build_chart: builds a ready-to-insert plotly block from plain data (labels and series), with the Chart Hands palette, and checks it. Types: line, area, bar, barh, combo (bars and lines with an optional secondary axis), scatter, bubble, pie, donut, treemap, sunburst, waterfall, funnel, histogram, box, violin, heatmap, radar and indicator. Line, area, bar and scatter charts can add a range slider.

    Compatible compact inputs also return staticBlock for HWPX export. The chart-hands-static block previews in Chart Hands; a compatible HWPX Exporter renders it to PNG from its data. It accepts JSON data only. Text including <, >, & and $ is displayed literally, not interpreted as HTML or math. Control characters and external sources are unsupported. If the block is absent, this path is unavailable. Saving still requires the export tool's approval flow.

  • check_chart: validates the content of a plotly block: YAML/JSON syntax, top-level keys, the fit value, common mistakes (unquoted # colours, dates, plain-string titles, JavaScript-like values, mismatched array lengths), the data shapes of advanced traces (treemap parents, sankey links, candlestick prices, waterfall measures, grid z, removed mapbox types) and Plotly's own attribute check.

  • read_charts: finds the plotly blocks in a note and reports line ranges, traces, titles and problems, 20 charts at a time.

None of the tools edits a note or draws a chart. The agent inserts the returned block with the host's own note-editing tools. The plugin works the same without an agent host.

CSV exploration for agents

A compatible agent host can call eda_profile with a vault-relative CSV path to inspect inferred column types, missing counts and rates, distinct counts, frequent text values, and numeric quartiles, mean, and sample standard deviation. Results contain five columns per page; continue with nextFrom. CSV files must have a header with unique nonempty names, at most 100 columns and 100,000 data rows, and be no larger than 5 MiB. Quoted fields and newlines are supported. Blank or whitespace-only cells are missing. A column is numeric only when every nonblank value is a finite number; other missing-value conventions are not inferred.

Call eda_analyze with path and an analysis specification, for example {"type":"correlation","columns":["duration","score"],"method":"pearson"} or {"type":"summary","columns":["score"]}. It reports the number of excluded nonnumeric or blank rows; correlation uses complete pairs and is null if fewer than two pairs or either variable has no variation. Quartiles use linear interpolation and standard deviation is the sample statistic. These tools read the vault without changing it, do not execute SQL or code from the file, and do not load Plotly or D3. The result is separate from a chart definition; an agent can use build_chart or write a Plotly block using the results. build_chart performs basic checks without loading Plotly; use check_chart for full Plotly schema validation.

For aggregate, analysis.limit defaults to 60 and accepts 1–100. The result includes groupCount, truncated, groups, and chart labels and values. When truncated, it also includes returnedGroups, the applied limit, maxLimit, and a note explaining how to obtain more groups. If there are more than 100 groups, use a smaller CSV; this tool has no filter or pagination parameter.

For a distribution, use {"type":"histogram","columns":["score"],"bins":10}; bins are left-inclusive and right-exclusive, except that the last includes the maximum. A constant column produces one bin.

About the agentinterop v1 provider

agentinterop v1 is the author's own convention, not an official or widely recognized standard. It lets an Obsidian plugin that runs an AI agent discover tools offered by other plugins through an MCP-like API, without those plugins knowing about any particular agent host.

How Chart Hands implements it:

  • The plugin instance holds the provider under the key Symbol.for('dev.agentinterop.capability-provider'). The provider has protocol: 'agentinterop', protocolVersion: 1, describe() (returns the provider info, instructions and tool definitions with JSON Schema inputs) and callTool(name, args) (returns content plus structuredContent, or an error with a code).
  • Tool definitions carry _meta hints under the dev.agentinterop/ prefix: effect (pure or read), group, keywords, resourceTypes and maxResultChars. A host shows these tools to the model only when a request matches a keyword such as "plotly", "chart" or "graph".
  • The plugin triggers the agentinterop:providers-changed workspace event on load and unload so a host can refresh its tool list.
  • All tools are read-only: they never modify notes or draw charts.

Installation

From the community plugins list (once Chart Hands is listed): open Settings → Community plugins, turn off Restricted mode if it is on, select Browse, search for Chart Hands, then select Install and Enable.

Manually:

  1. Download main.js, manifest.json and styles.css from the latest release.
  2. Create the folder <Vault>/.obsidian/plugins/chart-hands/ and put the three files in it. The .obsidian folder is hidden on some systems.
  3. Restart Obsidian, or reload the community plugins list, then enable Chart Hands in Settings → Community plugins.

Development

npm ci
npm run build   # bundles src/main.js with Plotly.js and D3 into main.js
npm test
npm run lint

License

MIT. Copyright (c) 2021 Dmitriy Shulha, (c) 2026 everydaymind. Plotly.js and D3 are bundled under their own MIT and ISC licenses.

HealthExcellent
ReviewPassed
About
Render Plotly charts from YAML or JSON code blocks and export them to PDF with automatic width fitting and aspect-ratio options. Provide agent tools to validate chart definitions and report chart locations, traces and problems (check_chart, read_charts) before committing changes.
ChartsAIExport
Details
Current version
0.10.11
Last updated
6 hours ago
Created
3 weeks ago
Updates
14 releases
Downloads
153
Compatible with
Obsidian 1.4.0+
Platforms
Desktop, Mobile
License
MIT
Report bugRequest featureReport plugin
Author
everydaymindeverydaymind
GitHubeverydaymind
  1. Community
  2. Plugins
  3. Charts
  4. Chart Hands

Related plugins

Sheet Plus

Create Excel-like spreadsheets and easily embed them in Markdown.

Claudian

Embeds Claude Code/Codex and other local Agents as AI collaborators in your vault.

Smart Connections

Find related notes and excerpts while writing. Your AI link building copilot displays relevant content in graph + list view. A local embedding model powers semantic search. Zero setup. No API key.

Fast Note Sync

Real-time sync of your vaults across server, mobile, and web; shareable with anyone; supports REST and MCP integrations to build your personal AI knowledge base.

ChatGPT MD

A seamless integration of ChatGPT, OpenRouter.ai and local LLMs via Ollama into your notes.

Copilot

Run AI agents such as Claude Code, Codex, and OpenCode inside your vault. Turn your second brain into a smart assistant that gets knowledge work done.

Karpathy LLM Wiki

Karpathy's LLM Wiki implementation plugin for Obsidian - turns notes and PDFs into a linked, LLM-powered knowledge base with entity pages, concept pages, graph-powered Q&A, and local-first privacy.

Agent Client

Chat with Claude Code, Codex, Gemini CLI, and more via the Agent Client Protocol — right from your vault.

Tracker

Track occurrences and numbers in your notes.

Text Generator

Generate text content using GPT-3 (OpenAI).