Skip to content

Instantly share code, notes, and snippets.

@mlajtos
Created June 3, 2026 08:41
Show Gist options
  • Select an option

  • Save mlajtos/e6f86323507ed30877b35f9f1f64fb37 to your computer and use it in GitHub Desktop.

Select an option

Save mlajtos/e6f86323507ed30877b35f9f1f64fb37 to your computer and use it in GitHub Desktop.
Remote Control of Cuis Smalltalk by Claude Code

Driving a live Cuis Smalltalk image from Claude Code (or any agent)

RemoteControl is a ~150-line HTTP bridge that runs inside a Cuis image and exposes two endpoints — evaluate Smalltalk, and screenshot the screen. With it, an external coding agent (Claude Code, Cursor, a shell script, anything that can curl) can drive a live image: define classes, recompile methods, inspect objects, and see the result on screen, all without restarting. It's the "Dan Ingalls in a box" loop — the agent works the way a Smalltalker does, against a running system.

This is the bridge from the Blueprint project, extracted to stand alone.


What you get

  • POST /eval — send Smalltalk source, get the result's printString back. Errors come back as HTTP status codes (200 ok / 400 undeclared variable / 500 runtime error + stack trace).
  • GET /screenshot — PNG of the world, optionally cropped to a morph or a rectangle.
  • ClassDescription>>safelyCompile:classified: — compile one method robustly over the bridge (auto-resumes noisy notifications, stamps it, flushes the changes file).

Everything lives on the RemoteControl class (class var Server); no Smalltalk globals are added.


Prerequisites

  • A Cuis Smalltalk image (tested on Cuis 7.x). https://cuis.st
  • The WebClient feature package (provides WebServer), which ships with Cuis under Packages/Features/WebClient.pck.st. Install it first if it isn't already:
    Feature require: 'WebClient'.

Install

File in RemoteControl.st (World menu → Open…Workspace, or drag the file onto the window, or):

Feature require: 'WebClient'.        "dependency"
(DirectoryEntry currentDirectory // 'RemoteControl.st') fileIn.

That adds the RemoteControl class and the safelyCompile:classified: extension. (The !requires: 'WebClient' header documents the dependency.)

Start it — from a Workspace

RemoteControl start
  • Listens on port 2347 (RemoteControl port).
  • RemoteControl stop shuts it down · RemoteControl isRunning queries · RemoteControl server returns the WebServer.
  • On macOS the first run triggers the network-permission prompt — grant it.

Never call RemoteControl start through the bridge. It tears down the very listener serving the request, and the port sits in TIME_WAIT ~30 s before it can rebind. Start/restart it from a Workspace.

Smoke test from your shell:

curl -s -X POST -H 'Content-Type: text/plain' --data-binary '3 + 4' http://localhost:2347/eval
# -> 7

The endpoints

POST /eval

Content-Type: text/plain, body is the Smalltalk source (a single expression-sequence; the last expression's value is printString-ed and returned).

status meaning
200 success; body is the result printString
400 the source referenced an undeclared variable; body names it (the classic "I typed a Squeak/Pharo class that doesn't exist in Cuis" mistake)
500 the call raised an Error; body is ClassName: message + a short stack trace trimmed at your DoIt frame
curl -s -X POST -H 'Content-Type: text/plain' --data-binary '1 + 1'      http://localhost:2347/eval   # 200 -> 2
curl -s -X POST -H 'Content-Type: text/plain' --data-binary 'NoSuchClass new' http://localhost:2347/eval   # 400 -> Undeclared variable: NoSuchClass
curl -s -X POST -H 'Content-Type: text/plain' --data-binary '1 foo'      http://localhost:2347/eval   # 500 -> MessageNotUnderstood: ...

Results are UTF-8 (non-ASCII printStrings come back intact). Binary values arrive as their printString (e.g. #[137 80 78 …]) — for images use /screenshot; for other binary, have the doIt write to disk and return the path.

GET /screenshot

No params → full world PNG. Crop with:

query behavior
?morph=ClassName first morph of that class (its displayFullBounds)
?id=<identityHash> one specific morph, unambiguous
?x=&y=&w=&h= an explicit rectangle
?pad=<n> margin around a morph crop
?raw=1 capture the live Display buffer (real on-screen pixels — shows drag trails / stale regions); default re-renders cleanly via imageForm:
curl -s -o /tmp/world.png  'http://localhost:2347/screenshot'
curl -s -o /tmp/win.png    'http://localhost:2347/screenshot?morph=SystemWindow&pad=20'

Single-quote any URL containing ? so your shell doesn't glob it. Don't poll /screenshot in a tight loop — each call renders the whole world and will starve the UI process.

Compiling methods: safelyCompile:classified:

curl -s -X POST -H 'Content-Type: text/plain' --data-binary @- http://localhost:2347/eval <<'EOF'
Integer safelyCompile: 'double
    ^ self * 2' classified: 'arithmetic'
EOF
# -> #double

Class-side methods: send to the metaclass — Integer class safelyCompile: '…' classified: '…'.

Prefer one safelyCompile:classified: per /eval round-trip over batching many compile: calls in one doIt — Cuis's parser has an intermittent bug that fires on long compile sequences, and per-method round-trips dodge it.


Wiring Claude Code (or any agent) to it

The whole interface is curl. A few conventions make the loop reliable:

  1. Send Smalltalk via a file, not inline. Write the source to /tmp/x.st and curl --data-binary @/tmp/x.st …. This sidesteps two compounding quoting layers (your shell's quotes and Smalltalk's ''…''); shell (…) can even open a subshell that kills the command before curl runs. A file body also gives you a re-runnable artifact.
  2. Declare all temps at the top of each doIt (| a b c |). Cuis's parser hits an intermittent True>>isUndefTemp bug more readily with temps declared mid-block.
  3. Verify by reading the new source back, not by checking the symptom is gone: (Foo >> #bar) sourceCode includesSubString: '…'.
  4. Watch curl's exit code. Non-zero = the change didn't apply, even if a later probe looks fine. (Note: saving the image — Smalltalk snapshot:… — freezes the VM and drops the socket; curl exits 52. That's not a failure; the snapshot completed.)

For Claude Code specifically, add a permission rule so it can call the bridge without prompting, e.g. in .claude/settings.json:

{ "permissions": { "allow": ["Bash(curl *)"] } }

…and a CLAUDE.md note telling the agent the bridge exists, the port, and the four rules above. Then it can develop against your running image: write a method, compile it over /eval, screenshot the result, iterate — with your eyes as the visual judge.


Why this works well in Cuis

Cuis is small, clean, and reproducible — a vector-graphics Morphic UI, a real package system, and an image you can snapshot. An agent operating inside a live image gets the Smalltalk superpower (everything is live, inspectable, and recompilable on the fly) without the agent needing to be a Smalltalk expert. The bridge is the thin seam that lets an outside agent reach in.

License / sharing

RemoteControl.st is offered freely for the Cuis community — adapt the port, add endpoints, harden it as you like. It is intentionally tiny so you can read the whole thing in one sitting.

'Claude<->Cuis bridge: RemoteControl. Filed out 3 June 2026.'!
'A minimal in-image HTTP server (port 2347) exposing POST /eval and GET /screenshot, so an external agent (e.g. Claude Code, driven by curl) can evaluate Smalltalk and read the screen of a live Cuis image. Also adds ClassDescription>>safelyCompile:classified: for robust method compilation over the bridge.'!
!provides: 'RemoteControl' 1 0!
!requires: 'WebClient' 1 0 nil!
SystemOrganization addCategory: #RemoteControl!
!classDefinition: #RemoteControl category: #RemoteControl!
Object subclass: #RemoteControl
instanceVariableNames: ''
classVariableNames: 'Server'
poolDictionaries: ''
category: #RemoteControl!
"-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- "!
!classDefinition: 'RemoteControl class' category: #RemoteControl!
RemoteControl class
instanceVariableNames: ''!
!RemoteControl class methodsFor: 'config' stamp: 'ML 13/May/2026 07:52:35'!
port ^ 2347! !
!RemoteControl class methodsFor: 'accessing' stamp: 'ML 12/May/2026 22:52:33'!
isRunning
^ Server notNil! !
!RemoteControl class methodsFor: 'accessing' stamp: 'ML 12/May/2026 22:52:33'!
server
^ Server! !
!RemoteControl class methodsFor: 'lifecycle' stamp: 'ML 13/May/2026 07:31:40'!
start
"Stop any previous server and start a fresh one on self port.
Endpoints:
POST /eval Smalltalk source body. JSON response.
GET /screenshot PNG of the world. Optional ?morph=Class or ?x&y&w&h to crop.
For programmatic method compilation, use ClassDescription>>safelyCompile:classified:
through /eval -- much smaller surface, same safety."
self stop.
Server := WebServer new listenOn: self port.
Server
addService: '/eval'
action: [:req | self handleEval: req]
methods: #('POST').
Server
addService: '/screenshot'
action: [:req | self handleScreenshot: req]
methods: #('GET').
^ Server! !
!RemoteControl class methodsFor: 'lifecycle' stamp: 'ML 12/May/2026 22:55:44'!
stop
"Stop and forget the server (idempotent)."
Server ifNotNil: [:s | s stopListener].
Server := nil! !
!RemoteControl class methodsFor: 'handlers' stamp: 'ML 26/May/2026 10:02:54'!
errorReportFor: anException
"500 body for a runtime error: a ClassName: message header line, then a short stack trace
trimmed at the user DoIt frame (so the Compiler/bridge harness frames below it are dropped)
and capped at 15 frames."
| report frames doItIndex |
report := String new writeStream.
report nextPutAll: anException class name asString; nextPutAll: ': '; nextPutAll: (anException messageText ifNil: [String new]).
anException signalerContext ifNotNil: [:ctx |
frames := ctx stackOfSize: 40.
doItIndex := frames findFirst: [:f | f printString = 'UndefinedObject>>DoIt'].
doItIndex > 0 ifTrue: [frames := frames first: doItIndex].
frames := frames first: (frames size min: 15).
report newLine; nextPutAll: 'Stack:'.
frames do: [:f | report newLine; nextPutAll: ' '; nextPutAll: f printString]].
^ report contents! !
!RemoteControl class methodsFor: 'handlers' stamp: 'ML 26/May/2026 10:03:06'!
handleEval: aRequest
"Evaluate the request body as Smalltalk and reply.
- success: 200 with the result printString.
- undeclared variable (e.g. a Squeak/Pharo class name absent from this image): 400 naming the
variable. The parser raises UndeclaredVariableReference (a Notification carrying the name)
during parsing; the inner handler reports it before the outer blanket resume can swallow it.
- any other benign Notification: auto-resumed with true.
- any other runtime Error: 500 with a header line plus a short stack trace.
Bodies are sent as UTF-8 bytes so char-count equals byte-count: WebRequest uses #size as the
Content-Length while encoding UTF-8 on the wire, so a non-ASCII String would corrupt framing."
| body |
body := aRequest content asString.
[[[
| value |
value := Compiler evaluate: body.
^ aRequest send200Response: value printString asUtf8Bytes contentType: 'text/plain; charset=utf-8'
] on: UndeclaredVariableReference do: [:ex |
aRequest sendResponseCode: 400 content: ('Undeclared variable: ' , ex varName) asUtf8Bytes type: 'text/plain; charset=utf-8' close: true]
] on: Notification do: [:ex | ex resume: true]
] on: Error do: [:ex |
aRequest sendResponseCode: 500 content: (self errorReportFor: ex) asUtf8Bytes type: 'text/plain; charset=utf-8' close: true]! !
!RemoteControl class methodsFor: 'handlers' stamp: 'ML 28/May/2026 16:21:49'!
handleScreenshot: aRequest
"PNG bytes with 200, or plain-text error with 500.
Query crop: ?id=<identityHash> | ?morph=<ClassName> | ?x&y&w&h, plus optional ?pad=<n>.
?raw=1 captures the LIVE Display buffer (actual on-screen pixels -- shows drag trails and any
stale/uninvalidated regions); the default re-renders the world via imageForm: (always clean,
so it hides such artifacts)."
[
| form rect query params raw bytes |
query := aRequest rawUrl copyAfter: $?.
params := query isEmptyOrNil ifTrue: [Dictionary new] ifFalse: [WebUtils decodeUrlEncodedForm: query].
raw := (params at: 'raw' ifAbsent: ['0']) = '1'.
form := raw
ifTrue: [Display]
ifFalse: [(WorldMorph allInstances first) imageForm: 32].
rect := self rectFromQuery: query in: form boundingBox.
(rect notNil and: [rect area > 0]) ifTrue: [form := form copy: rect].
bytes := ByteArray streamContents: [:s | PNGReadWriter putForm: form onStream: s].
^ aRequest send200Response: bytes contentType: 'image/png'
] on: Error do: [:ex |
| msg |
msg := ex class name asString, ': ', (ex messageText ifNil: ['']).
aRequest sendResponseCode: 500
content: msg asUtf8Bytes
type: 'text/plain; charset=utf-8'
close: true]! !
!RemoteControl class methodsFor: 'private' stamp: 'ML 28/May/2026 15:57:08'!
cropRectFor: morph pad: pad in: bigRect
"World-coordinate crop rectangle for morph: its displayFullBounds (geometric -- works even for
a fully-transparent morph, where displayBounds is nil, and includes the morph submorphs),
expanded by pad on every side, then clamped to the form."
| b |
b := morph displayFullBounds.
^ ((b origin - pad) corner: (b corner + pad)) intersect: bigRect! !
!RemoteControl class methodsFor: 'private' stamp: 'ML 28/May/2026 15:57:08'!
rectFromQuery: queryString in: bigRect
"Returns a Rectangle, or nil if the query has no crop intent. Raises Error if there IS crop
intent but it can not be honored (unknown morph/id; partial x/y/w/h spec).
Crop intents: ?id=<identityHash> (one specific morph -- unambiguous), ?morph=<ClassName> (first
morph of that class), or explicit ?x&y&w&h. Any morph crop honours an optional ?pad=<n> margin
and uses displayFullBounds so transparent morphs and submorphs are handled."
| params morph idString morphName hasCoord allCoord pad |
queryString isEmptyOrNil ifTrue: [^ nil].
params := WebUtils decodeUrlEncodedForm: queryString.
pad := (params at: 'pad' ifAbsent: ['0']) asNumber.
idString := params at: 'id' ifAbsent: [nil].
idString ifNotNil: [
morph := nil.
WorldMorph allInstances first allMorphsDo: [:m |
(morph isNil and: [m identityHash printString = idString]) ifTrue: [morph := m]].
morph ifNil: [^ self error: 'Morph not found by id: ', idString].
^ self cropRectFor: morph pad: pad in: bigRect].
morphName := params at: 'morph' ifAbsent: [nil].
morphName ifNotNil: [
morph := nil.
WorldMorph allInstances first allMorphsDo: [:m |
(morph isNil and: [m class name asString = morphName]) ifTrue: [morph := m]].
morph ifNil: [^ self error: 'Morph not found: ', morphName].
^ self cropRectFor: morph pad: pad in: bigRect].
hasCoord := #('x' 'y' 'w' 'h') anySatisfy: [:k | params includesKey: k].
hasCoord ifFalse: [^ nil].
allCoord := #('x' 'y' 'w' 'h') allSatisfy: [:k | params includesKey: k].
allCoord ifFalse: [^ self error: 'Need all of x, y, w, h'].
^ (((params at: 'x') asNumber) @ ((params at: 'y') asNumber)
extent: ((params at: 'w') asNumber) @ ((params at: 'h') asNumber))
intersect: bigRect! !
!ClassDescription methodsFor: '*RemoteControl' stamp: 'bridge 6/3/2026 00:00'!
safelyCompile: source classified: aCategory
"Compile one method, with the three things bridge callers always want:
- Noisy Notifications (InMidstOfFileinNotification, UndeclaredVariableWarning, ...)
are resumed so they don't poison the compile.
- The method is stamped via Utilities changeStamp.
- Smalltalk forceChangesToDisk runs so #authorAndStamp is readable immediately.
Returns the compiled selector. Errors propagate.
Dock safelyCompile: 'foo ^ 42' classified: 'misc'
Dock class safelyCompile: 'hi ^ ''hello''' classified: 'misc'
"
| selector |
[
selector := self
compile: source
classified: aCategory
withStamp: Utilities changeStamp
notifying: nil
] on: Notification do: [:ex | ex resume: true].
Smalltalk forceChangesToDisk.
^ selector
! !
@thiagoslino

Copy link
Copy Markdown

The PNGReadWriter class is located in the Graphics-Files-Additional package, so it also needs to be loaded with:

Feature require: 'Graphics-Files-Additional'

Additionally, the following dependency should be added to RemoteControl.st:

!requires: 'Graphics-Files-Additional' 1 30 nil!

Without this package, the code fails because PNGReadWriter is not available.

See https://gist.github.com/thiagoslino/814f313c46480a5888da2c2ca5d67d62

@mlajtos

mlajtos commented Jul 1, 2026

Copy link
Copy Markdown
Author

Superseded by https://github.com/mlajtos/Cuis-RemoteControl

@thiagoslino Thank you for point out the shortcoming – it is fixed in the package.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment