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]"]}
]}
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/bendhints, arotateon the species, or a largerbond_lengthfix 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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.