Skip to main content

curlyarrows

Draw molecules, reaction schemes and arrow-pushing mechanisms from a JSON spec an LLM can write directly: molecules as explicit atom/bond graphs (or SMILES), and curly arrows between any two atoms or bonds, routed automatically around bonds and labels. Natural language and reaction SMILES still work for plain schemes.

{"title": "SN2",
 "frames": [
   {"species": [{"atoms": [{"id": "O1", "element": "O", "charge": -1, "hydrogens": 1}], "bonds": []},
                "[CH3:2][Br:3]"],
    "arrows": ["O1 -> 2", "2-3 -> 3"],
    "caption": "backside attack"},
   {"species": ["[CH3:2][OH:1]", "[Br-:3]"]}
 ]}

SN2 E2 hydration Grignard

Install

pip install curlyarrows                 # library + CLI (rdkit, cairosvg, requests)
pip install "curlyarrows[mcp]"          # + the MCP server

From a checkout: uv sync (or uv sync --all-extras for the MCP server, the Indigo backend and OPSIN, which needs Java).

On a minimal Linux (Docker python:*-slim, some sandboxes) RDKit's drawing module needs four system libraries: apt-get install libx11-6 libxext6 libxrender1 libexpat1. PNG/PDF output additionally needs the cairo library (libcairo2); without it SVG still works and curlyarrows.png_available() tells you which case you are in.

Using it from claude.ai (Skill)

dist/curlyarrows-skill.zip (built by uv run python tools/build_skill.py) is a claude.ai Skill: upload it under Settings > Capabilities > Skills. It teaches Claude the frames spec, tells it to pip install curlyarrows in its sandbox, and ships three worked examples. The sandbox needs network access set to allow package managers (the default).

Use

import curlyarrows

curlyarrows.draw("ethanol + acetic acid -(cat. H2SO4)-> ethyl acetate + water", "ester.png")
curlyarrows.draw("aspirin", "aspirin.svg")
svg = curlyarrows.draw("c1ccccc1Br.OB(O)c1ccccc1>[Pd]>c1ccc(-c2ccccc2)cc1")   # returns SVG text

curlyarrows.draw({
  "title": "Acid-catalysed hydration",
  "steps": [
    {"species": ["[CH2:1]=[CH:2][CH3:3]", "[H:5][OH2+:4]"], "arrows": ["1-2 -> 5", "5-4 -> 4"]},
    {"species": ["[CH3:1][CH+:2][CH3:3]", "[OH2:4]"],       "arrows": ["4 -> 2"]},
  ]}, "hydration.png")

spec = curlyarrows.parse("cyclohexanone + NaBH4 in MeOH -> cyclohexanol")   # inspect / edit before drawing
spec.to_dict()

CLI:

curlyarrows "benzene -[Br2, FeBr3]-> bromobenzene" -o out.png
curlyarrows mech.json -o mech.svg
curlyarrows "..." --json            # show the parsed spec instead of drawing
curlyarrows resolve "sodium borohydride" "sertraline"
curlyarrows guide                   # the prompt text that teaches an LLM the spec
curlyarrows tools                   # Claude tool definitions (JSON)
curlyarrows serve                   # MCP server

For agents (MCP)

.mcp.json in this repo registers the server for Claude Code. Tools: draw_chemistry, draw_mechanism, resolve_name, parse_chemistry. Each draw tool returns the output path, the parsed spec and the PNG as an image block, so the agent can look at what it drew.

claude mcp add curlyarrows -- uv run --project /path/to/chem_draw curlyarrows serve

For direct tool use with the Claude API: curlyarrows.guide.anthropic_tools() (or openai_tools()), and curlyarrows.GUIDE is a system-prompt snippet.

Input forms

Input Example Becomes
name / formula / SMILES ibuprofen, NaBH4, CC(=O)O, smiles:C1CC1 Species
reaction SMILES A.B>agents>C.D Reaction
prose A + B with X in Y at 0 °C gives C Reaction
chained prose A -> B [reagents] -> C [reagents], A -[X]-> B, A <=> B Scheme
JSON {"reactants":..}, {"steps":[..]} Reaction / Scheme / Mechanism

Prose rules: reactants are split on +, and, commas (not inside 1,2-); with/using X goes above the arrow, in X / at X / temperatures go below; reagent-like species (bases, reductants, oxidants, catalysts) listed as reactants are moved onto the arrow when a real substrate remains. Anything that cannot be resolved raises ResolveError naming the token, so an agent can retry with smiles:....

Name resolution order: built-in dictionary (~1100 solvents, reagents, ions, common organics) → the text is SMILES → OPSIN (if installed + Java) → PubChem → NCI CIR. Network hits are cached in ~/.cache/curlyarrows/. offline=True disables the network.

The v2 model: graphs, frames, arrows

from curlyarrows import Molecule, Frame, Mechanism, draw

hydroxide = Molecule(name="hydroxide")
hydroxide.add_atom("O1", "O", charge=-1, hydrogens=1)

mebr = Molecule.from_smiles("[CH3:2][Br:3]")          # atom maps become ids "2", "3"

mech = Mechanism(title="SN2", frames=[
    Frame(species=[hydroxide, mebr], arrows=["O1 -> 2", "2-3 -> 3"], caption="backside attack"),
    Frame(species=["[CH3:2][OH:1]", "[Br-:3]"]),
])
draw(mech, "sn2.png")

Molecule (curlyarrows.graph): add_atom(id, element, charge=, radical=, hydrogens=, lone_pairs=, label=), add_bond(a, b, order=1|2|3|1.5, stereo=), from_smiles(smiles, ids="map"|"index"), from_dict/to_dict, layout(rotate=, flip_x=, flip_y=), set_coords({...}). Hydrogen counts and lone pairs are inferred when omitted and never second-guessed when given, odd valences are allowed (mechanisms need them), and an unknown element string like "Nu" or "Ph" becomes a placeholder atom drawn with that text.

Arrow (curlyarrows.arrows): "O1 -> C1" (two electrons), "C1-Br ~> Br" (one electron, fishhook). Endpoints are atom ids or a-b bonds; a bond that does not exist yet is a forming bond. Dict form {"from", "to", "electrons", "side", "bend", "label"} forces the side or curvature.

Routing: every arrow gets a family of cubic Bézier candidates (both sides, several bends, several departure and arrival angles, the lone-pair free side of the atom). Each is sampled and scored for bond crossings, grazing other atoms' labels, overlapping captions, and crossing earlier arrows, plus small shape penalties. Lowest score wins. Short arrows (lone pair to the adjacent bond, bond onto its own atom) get a minimum arc length so they read as arrows, two arrows leaving the same bond take opposite sides, and arrows sharing a target get staggered tips. tools/route_debug.py scene.json out.svg draws all candidates for a scene to debug a route.

After the greedy pass, two refinement sweeps re-route each arrow against all the others and keep a change only if it helps that arrow without hurting another, which undoes most bad choices the order forced. If the best route in some frame still grazes a label or crosses a bond (four arrows on a four-membered ring, say), the mechanism is re-rendered once with 1.45× longer bonds and the cleaner version is kept (Style(auto_scale=False) or "style": {"auto_scale": false} to disable, "style": {"bond_length": 45} to force a size).

Frame / Mechanism (curlyarrows.scene): a frame is species side by side with + and routed arrows; caption, next_arrow (forward, equilibrium, resonance, retro, none), next_label, plus=False. Ids must be unique within a frame, or qualify them as "0:O1". Species can be Molecule, a molecule dict, {"smiles": ..., "rotate": 30}, or a SMILES string. An intramolecular attack ("N1 -> C7" inside one species) shapes the layout: the molecule is drawn as if that bond existed, so the nucleophile sits next to its target and the arrow is short, the way a chemist draws a ring closure (forming_layout=False on the frame turns it off; "forming": [["N1", "C7"]] on a molecule adds pairs by hand).

Errors are written for an agent: an unknown endpoint lists the ids available in that frame.

v1 spec (still supported)

The older atom-map-only mechanism spec ({"steps": [{"species": [...], "arrows": ["1 -> 2-3"]}]}) and the natural-language / reaction-SMILES paths are unchanged; the v1 mechanism class now lives at curlyarrows.spec.Mechanism.

examples/v2_staudinger.py is a seven-frame Bertozzi–Staudinger ligation written cold against the frames spec (placeholder Ph/R/Me groups, a four-membered ring with four arrows, an intramolecular acyl transfer); examples/v2_staudinger.png is its output.

Styling

curlyarrows.Style(bond_length=30, atom_colors=True, curly_color="#7b1fa2", curly_opacity=0.6, max_row_width=1100, show_map_numbers=False, ...) and pass style= to any draw call, or put the overrides in the mechanism dict as "style": {...}. Curly arrows are purple (a colour RDKit does not use for any element) and 60 % opaque by default, so they read against black bonds and coloured heteroatoms without hiding anything they cross. "background": null (or --transparent) drops the white background for web pages and slides. "theme": "dark" (or --theme dark) draws light strokes and text on a dark background for dark-mode viewers; "theme": "auto" makes an SVG flip to dark by itself via a prefers-color-scheme media query (a PNG cannot adapt, so it stays light). --bw, --bond-length, --max-width, --show-maps, --arrow-color, --arrow-opacity, --transparent on the CLI.

The landscape: ways to draw chemistry programmatically

What exists, what it is good for, and what this package uses.

2D depiction engines (structure → image)

Tool Language Notes
RDKit rdMolDraw2D Python/C++ Used here. SVG/PNG, atom pixel coords available (GetDrawCoords), reactions via DrawReaction, highlights, no curly arrows natively
Indigo (EPAM) Python/Java/.NET Optional backend here (curlyarrows.render.indigo_backend). Very good automatic reaction layout, SVG/PNG/PDF, no curly arrows
CDK / CDK Depict Java (web service) https://www.simolecule.com/cdkdepict/depict/bow/svg?smi=... renders molecules and reactions incl. reaction SMILES with agents; excellent abbreviation handling. Needs Java or the hosted service
Open Babel obabel -osvg/-opng C++/CLI Quick depictions, many formats; style is dated
OpenChemLib Java/JS Actelion's toolkit; used by many web tools
Ketcher (EPAM) JS Full web editor; headless depiction via its API, molfile/rxnfile in/out
ChemDoodle Web / Kekule.js / JSME / Marvin JS JS Browser editors and renderers
mol2chemfig + chemfig (LaTeX) TeX Publication-quality; chemfig natively supports curly arrows (\chemmove) and \schemestart ... \arrow schemes. Needs a TeX install
ChemDraw (PerkinElmer/Revvity) desktop Industry standard; CDXML is readable (rdkit can read CDXML), scriptable only via its own JS/Apple events
Marvin (ChemAxon) molconvert Java High quality, commercial
PyMOL / 3D tools – 3D only; not for schemes

Name ↔ structure

Tool Notes
OPSIN Open-source IUPAC parser (Java); py2opsin wraps it. Best for systematic names
PubChem PUG-REST Trivial names, drugs, abbreviations; network
NCI/CADD CIR Fallback resolver; network
STOUT / ChemBERTa-style models ML name→SMILES; not used
RDKit MolFromSmiles SMILES/SMARTS/InChI/molfile/CDXML parsing

Reaction / mechanism representations

Representation Notes
Reaction SMILES A.B>agents>C Universal, used by RDKit/Indigo/CDK; atom maps [C:1] carry identity across steps
RXN / RDF / CML / CDXML File formats for schemes; RDKit reads RXN and CDXML
Atom-mapped SMILES + arrow list What this package uses for curly arrows: {"species": [...], "arrows": ["1 -> 2-3"]}
chemfig \chemmove TeX way to draw electron-pushing arrows
Hand-drawn SVG overlays What curlyarrows.render.svg.curly_arrow does over RDKit atom coordinates

Where this package sits: RDKit for molecule depiction (fonts are converted to paths, so SVGs are self-contained), its own SVG compositor for + signs, reaction arrows (forward, equilibrium, resonance, retro), reagent/condition text with sub/superscripts, row wrapping, titles and captions, and quadratic-Bézier curly arrows (full head or fishhook) anchored on RDKit's atom pixel coordinates. cairosvg turns the SVG into PNG/PDF.

Layout of the code

src/curlyarrows/
  __init__.py         draw(), draw_svg(), parse() – public API (v1 inputs and v2 objects)
  graph.py            v2: Atom / Bond / Molecule (inference, RDKit bridge, layout)
  arrows.py           v2: Arrow, SceneGeom, candidate routing + scoring, arrow SVG
  scene.py            v2: Frame / Mechanism
  spec.py             Species / Reaction / Scheme / Mechanism / MechStep / CurlyArrow
  parse.py            natural language → spec
  resolve.py          name → SMILES (dictionary, OPSIN, PubChem, CIR, cache)
  names.py            the built-in dictionary
  guide.py            GUIDE prompt text + JSON schemas + tool definitions
  style.py            Style dataclass
  render/
    rdkit_backend.py  molecule → SVG body + atom pixel coords
    svg.py            Block compositor, arrows, text, curly arrows
    layout.py         v1 reactions, schemes, mechanisms
    graph_backend.py  v2 molecule -> SVG using the graph's own coordinates
    scene_layout.py   v2 frames and mechanisms
    raster.py         SVG → PNG/PDF
    indigo_backend.py optional Indigo renderer
  cli.py              `curlyarrows` command
  mcp_server.py       `curlyarrows serve`
examples/gallery.py     v1 examples;  examples/v2_gallery.py  v2 examples
tools/route_debug.py    visualise arrow routing candidates
tests/                pytest

Known limits

  • Prose parsing is heuristic. Agents should prefer the frames spec; the parser is for convenience.
  • Routing is heuristic: four arrows on a four-membered ring or three around one atom are legible only after the automatic scale-up, and a lone pair attacking an atom one bond length away is a short S-curve. side/bend hints, a rotate on the species, or a larger bond_length fix the rest.
  • Arrows anchor on atom stand-off circles and bond midpoints; a specific lone pair or π-face is not chosen.
  • Routing is pure Python at roughly 0.1 s per arrow, fine for mechanisms, slow for hundreds of arrows.
  • Stereochemistry wedges come from RDKit; relative (cis/trans) descriptors in names are only honoured when the resolver returns stereo-marked SMILES.

Metadata

Release files for curlyarrows 0.2.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for curlyarrows 0.2.3
File Size Uploaded Provenance Actions
curlyarrows-0.2.3.tar.gz 81.4 kB Without provenance Details for curlyarrows-0.2.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for curlyarrows 0.2.3
File Interpreter ABI Platform Actions
curlyarrows-0.2.3-py3-none-any.whl Python 3 none any Details for curlyarrows-0.2.3-py3-none-any.whl

Total release size: 172.9 kB

Release files / curlyarrows-0.2.3.tar.gz

Download URL curlyarrows-0.2.3.tar.gz
Size 81.4 kB
Tags Source
SHA-256 checksum
How to use checksums
f237b0532421d48dbbc8bbe2a558d20fb040bd6fa60b92bacb8cf781c0f66932
BLAKE2b-256 checksum
How to use checksums
ebd52ed34330dc3a5429c6529a5b0aabb650661d91aba67a4068b1d501c4490f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / curlyarrows-0.2.3-py3-none-any.whl

Download URL curlyarrows-0.2.3-py3-none-any.whl
Size 91.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ef1212a7079e9e2aefc218b91ea4001b12fb98a39e8dd98b23f95eb6cc8c098a
BLAKE2b-256 checksum
How to use checksums
fbdf6d5e29528a8b5f676fe8f9fb982d20ea1e51a38e1fc8d83fbac9e60e9d65
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for curlyarrows 0.2.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page