Conversation-style tutorial · photonics · ports & routing

gdsfactory photonics: three ways to get ports + drag-to-reroute

Before you can route, klink has to know where each device's ports are. This tutorial walks three ways to get klink Ports onto a device: hand-drawn triangle conversion (custom devices), standard gdsfactory auto-ports, and PDK blackbox stub-convention harvest, with a cautionary example where the triangle base is drawn wrong. Once the ports exist, it runs the SEND selection → route → move device → reroute interactive loop. Every step spells out what the agent said, which tool it called, and what came back, with the real KLayout screenshot from that moment.

This is written for humans and agents alike: each turn first says in plain language what it's doing, then gives the exact tool call and return value. Every screenshot is from this session running against a live KLayout — the layout canvas is rendered offscreen with view.screenshot, and only the one shot that needs to show a toolbar button (SEND) is a full-window capture. The demo runs in throwaway tabs and never touches an existing working tab; it uses a few ordinary layers (1/0 waveguide, 12/0 routing, 999/99 port markers) and no real process PDK. Everything here is also packaged as a runnable starter: python example_template/photonics/gf_ports.py --port <port>, with --reroute after a drag reconnecting without rebuilding.

Part 1 · three ways to get a Port

User
I drew a custom device (not from a PDK), with a 0.8 µm access waveguide on each side. Mark the ports at the two waveguide ends for me — I drew a little triangle pointing outward at each one.
klink agent

The hand-drawn port convention is simple: one triangle (a 3-point polygon) is one port — the triangle's apex direction is the port orientation, and the triangle's base length is the port width. So the base has to match the waveguide width (0.8 µm here); you can't draw it any old size. Both your triangles sit with their base right on the 0.8 µm waveguide end and their apex pointing out — textbook. Here's the before (yellow is your hand-drawn triangles):

shape.insert_boxes(device body + two 0.8µm waveguides → 1/0) shape.insert_many(two triangle markers → 999/99, base=0.8µm, apex outward) view.screenshot(bbox_um=[-13,-4,13,4])
A pink custom device with a thin waveguide on each side, each waveguide end has a yellow triangle pointing left and right respectively
Turn 1 · custom device + two hand-drawn triangle port markers (yellow). Triangle base = waveguide width 0.8 µm, apex = port orientation.

Then one call turns the triangles into standard klink Ports — it reads each triangle's geometry, infers orientation, width and position, marks a proper Port, and deletes the raw triangle:

recognize_handdrawn_ports(cell="GFPORTS", layer="999/99", port_type="optical") → P0 center=[-10,0] orient=180° width=0.8 attached=True → P1 center=[ 10,0] orient=0° width=0.8 attached=True
The same device, the two yellow triangles are gone, the waveguide ends now have red standard port markers labelled klink_port.P0 and klink_port.P1
Turn 1 (cont.) · after conversion: two standard Ports P0 / P1, width exactly 0.8 µm, orientation 180°/0°. Because the base sits right on the waveguide edge, klink also recognizes them as edge-attached ports (attached=True) that can slide along the edge during routing.
User
What if the triangle base is drawn wrong? Say much wider than the waveguide.
klink agentcautionary example

Good question — precisely because base = width, apex = orientation, a sloppy triangle goes wrong. Here's a triangle with a 2.5 µm base (three times too wide) on a 0.8 µm waveguide, and the conversion comes out all wrong: width read as 1.95 µm (doesn't match the waveguide), orientation dragged off to 270° by the long-edge heuristic (pointing down, not out at 0°), and because the base can't reach the waveguide edge, the port is floating (attached=False).

shape.insert_many(triangle marker, base=2.5µm ← drawn wrong) recognize_handdrawn_ports(cell="GFERR", layer="999/99") → P0 center=[0.75,0] orient=270°(✗ want 0°) width=1.95(✗ want 0.8) attached=False(✗ floating)
A thin waveguide with an obviously oversized triangle drawn at its end
The wrong triangle with a 2.5 µm base (way wider than the 0.8 µm waveguide).
After conversion the port is a square much larger than the waveguide, floating at the waveguide end, clearly mismatched
The resulting Port is a ~2.5 µm square floating at the 0.8 µm waveguide end — wrong size, position, and orientation.

Takeaway: when hand-drawing ports, make the triangle base match the waveguide width and the apex clearly point outward. klink won't guess the width you meant — it faithfully turns the geometry you drew into a port. Draw it accurately and the port is accurate.

User
What about a standard gdsfactory device? Like a 1×2 MMI splitter.
klink agent

A standard gdsfactory device carries its own port definitions, so there's no hand-drawing. When you place the component, klink marks the ports as klink Ports automatically from gdsfactory's Port objects — position, orientation and width all follow the component. An mmi1x2 has 3 ports: input o1 facing left, two outputs o2/o3 facing right.

place_gdsfactory_components(cell="GFAUTO", [{component:"mmi1x2", center_um:[0,0], port_nets:{o1:"in",o2:"out0",o3:"out1"}}]) → SPL1.o1 center=[-10,0] orient=180° width=0.5 → SPL1.o2 center=[15.5,0.62] orient=0° width=0.5 → SPL1.o3 center=[15.5,-0.62] orient=0° width=0.5
A gdsfactory MMI 1x2 splitter: input taper on the left, MMI body in the middle, two output tapers on the right, three ports auto-marked
Turn 3 · standard gdsfactory mmi1x2: 3 ports marked as klink Ports automatically (o1 input / o2 o3 outputs), width 0.5 µm = gdsfactory's default waveguide. Not one hand-drawn stroke.
User
And a PDK blackbox device? It's just a blob of opaque geometry — how does klink know where the ports are?
klink agent

A foundry PDK blackbox doesn't hand you gdsfactory Port objects, but it usually follows a convention: small stub boxes on the waveguide layer at the cell boundary (one common convention is 0.5×0.5 µm boxes), each stub being one optical port. klink's approach: you tell it the convention (which layer is the waveguide, how big the stub is), and it harvests the ports from the live instance geometry — ports are derived data, so if you move the device in the GUI, just re-harvest to refresh. Here's a synthetic "foundry-style" blackbox (opaque body on 60/0, two 0.5×0.5 stubs on waveguide layer 1/0):

port.harvest_blackbox(cell="GFBB", tags={"SYNTH_BB":"bb"}, wg_layer="1/0", stub_size_um=0.5) → bb0_0 center=[-4.25,0] orient=180° → bb0_1 center=[ 4.25,0] orient=0°
A square blackbox device outline labelled SYNTH_BB, with a red port marker bb0_0 and bb0_1 on the left and right boundary
Turn 4 · synthetic blackbox SYNTH_BB: 2 ports harvested by the stub convention (bb0_0 / bb0_1), orientation inferred from stub position.
This harvest mechanism is convention-based and configured per PDK — each foundry's stub layer and size differ, so you pass the convention from your own pdk.py; klink ships no default. A real foundry PDK blackbox follows the exact same path, just with that PDK's real wg_layer / stub_size_um. (This site publishes no specific foundry's PDK content.)

Part 2 · SEND → route → move → reroute

Once the ports exist, you can run klink's interactive loop. Below, the custom device from above (hand-drawn port P0) and a gdsfactory mmi1x2 are placed facing each other, sharing one net link0 — then connect, move one, and reconnect.

User
I selected those two ports in KLayout and hit SEND on the toolbar — connect them.
klink agent

When you hit SEND, the two selected ports enter my session memory with a durable id — so later when you say "the two ports I just sent" I resolve them exactly, no need to re-quote coordinates. Both are on net link0 (the custom device's P0 on the left, the MMI's input o1 on the right).

selection.send_context(source="gf_tutorial") → {status:"sent", count:2, send_seq:2} # two link0 ports enter agent memory
KLayout toolbar with the SEND button boxed in red and an arrow to a label; the cell tree on the left lists klink_port.P0 (link0) and the MMI ports
Turn 5 · hit SEND (red box is annotation) to hand the two link0 ports to the agent. The cell tree on the left shows klink_port.P0 (link0) and the MMI's three ports.
User
OK, route it.
klink agent

Both ports are on link0 and face each other, so route a waveguide straight across, written to the 12/0 routing layer. Before, the gap between them was empty (left); after, they're connected (the darker waveguide on the right).

route_gdsfactory_ports(cell="GFLOOP", route_layer="12/0", all_two_port_nets=True) → {ok:true, MMI.o1 → P0, length_um:40, crossings:0}
Custom device on the left, MMI on the right, the two ports face each other with an unconnected gap between them
Before route: the two link0 ports face each other with an empty gap.
The same two devices, the gap is now bridged by a straight waveguide
After route: a 40 µm straight waveguide (12/0) connects the two ports, 0 crossings.
User
I moved the left device up by 20 µm.
klink agent

The device moved, but that route is still pinned to the old coordinates — one end is now floating, disconnected from the moved device. This is the "just dragged, not yet rerouted" moment: the route is stale. (Here a logged exec.python reproduces the GUI drag — it only changes the device position, not the route, not the net.)

exec.python(shift device A geometry + its P0 port marker up 20µm) # the route stays at y=0, doesn't follow → stale
The left device has clearly moved up, the original horizontal route still sits below, one end floating, not connected to the device
Turn 7 · after moving device A up 20 µm: the old route (the dark horizontal line at the bottom) stays put, one end floating — the route is stale, plainly visible.
User
Reconnect it.
klink agent

Re-run the same route call — it re-reads the live port positions (P0 is now at its new coordinates), and replaces the old route with a waveguide that follows to the new spot. The device moved, the wire caught up with one smooth S-bend, nothing else changed.

route_gdsfactory_ports(cell="GFLOOP", route_layer="12/0", all_two_port_nets=True) → {ok:true, MMI.o1 → P0(new position), length_um:60} # 40µm straight → 60µm S-bend
The moved-up left device now connects back to the right MMI through a smooth S-shaped waveguide, the layout is whole again
Turn 8 · after reroute: a smooth S-bend waveguide runs from the moved device back to the MMI input — the component moved, the waveguide reconnected. This "mark ports → route → drag → reroute" loop is the core of iterating a photonic layout.

Verify, not screenshot

As in Core Concepts, screenshots are for humans; the real proof of done is the structured return. Every step here has checkable numbers: hand-drawn conversion gives width=0.8 (exactly the waveguide width), the cautionary example gives width=1.95 / orient=270° / attached=False (faithfully reflecting the wrong triangle), auto-ports 3 / blackbox harvest 2, route length=40 → reroute length=60, crossings=0. The images just make it obvious; success is judged on those returns.

Next

Pick the port method by situation: your own device → hand-drawn triangles (remember base = width); a standard gdsfactory device → ports come automatically; a foundry blackbox → harvest by its stub convention. Once the ports exist, the SEND / route / drag / reroute loop treats all three the same. To see a whole gdsfactory script taken over in one call into this loop (thermo-optic MZI, tilted grating couplers, electrical nets), see the gdsfactory MZI takeover tutorial; for more real conversations of the agent turning a sentence into a device / DRC / LVS, see the chat walkthroughs at the top of the tutorials page.