FTP & External Storage

FTP CONNECT authenticates to a file server and captures the connection with SET. Every subsequent operation is subject-first - FTP ?conn VERB ... - exactly like EMAIL and SSH. One verb covers plain FTP, FTPS and SFTP; SECURE is what selects an encrypted transport.

Connecting

A server to practise against. example.ocalt.com runs the examples on this page: FTP and FTPS on port 2121, SFTP on 2222, signing in as demo with the password demo. Key authentication works too, using the key published at https://example.ocalt.com/files/demo_key. The server is read-only, so uploads, renames and deletions are refused there; point those examples at a server of your own.
Password Authentication
FTP CONNECT "example.ocalt.com" AS "user" WITH "password" SET ?conn
Custom Port
FTP CONNECT "example.ocalt.com" ON "2121" AS "user" WITH "password" SET ?conn
(* Default is 21 for plain FTP, 22 when SECURE selects SFTP *)
Encrypted Transport
FTP CONNECT "example.ocalt.com" AS "user" WITH "password" SECURE SET ?conn
AFTER FTP CONNECT "example.ocalt.com" ON "2222" AS "demo" KEY "/root/keys/demo_key" SECURE SET ?conn2
SECURE is the only switch. Without it the connection is plain FTP. With it, the transport is encrypted - FTPS when authenticating by password, SFTP when authenticating by KEY. There is no separate FTPS or SFTP verb to learn, and no protocol string to get wrong.

SFTP

A connection made with KEY is a live SSH session with the SFTP subsystem open on it. Every FTP ?conn verb on this page works over it exactly as it does over FTP: listing, inspecting, transferring, organising, TRANSFER between servers and EXTERNAL BIND. The default port is 22; ON overrides it.

KEY names a private key in your namespace, such as "/root/keys/id_ed25519". The key must be unencrypted and in OpenSSH, PKCS#8 or PEM RSA form, which covers what ssh-keygen produces. A key protected by a passphrase is refused with a message that says so.

The server's identity is remembered. The first time your namespace connects to an SFTP server, that server's host key is recorded. Every later connection, from FTP CONNECT or from EXTERNAL BIND, must present the same key. If it does not, the connection is refused with SFTP refused: the host key of ... is not the one recorded, because either the server was rebuilt or something is intercepting the connection. Nothing is sent to a server that fails this check.

SFTP by Key, Then the Ordinary Verbs
FTP CONNECT "example.ocalt.com" ON "2222" AS "demo" KEY "/root/keys/demo_key" SECURE SET ?conn
AFTER FTP ?conn LIST "/var/www" SET ?items
AFTER FTP ?conn UPLOAD "/root/build.zip" TO "/var/www/releases/build.zip"
AFTER FTP ?conn CLOSE
An SSH server that accepts only a password. SECURE with a password selects FTPS, so such a server is reached through the SSH verb, which authenticates with PASSWORD and moves files with UPLOAD ... TO and DOWNLOAD ... INTO.

Listing and Inspecting

List a Directory
FTP CONNECT "example.ocalt.com" AS "user" WITH "password" SET ?conn
AFTER FTP ?conn LIST "/public_html" SET ?items
AFTER FOREACH ?items SET ?item
OPEN
  EMIT ?item("name") & " — " & ?item("size") & " bytes"
CLOSE
Each entry carries name, is_dir, size and modified - the same shape FILE LIST returns for your own namespace and DIRECTIVE "id" LIST FROM returns for a machine, so all three can be walked by identical code.
Stat a Single Path
FTP ?conn STAT "/public_html/index.html" SET ?info
AFTER EMIT ?info("size")

Transferring Files

UPLOAD sends a file from your namespace to the server; DOWNLOAD brings one back. The prepositions are the same ones SSH uses, so the two verbs read identically.

DOWNLOAD streams. The file moves from the server into your namespace in pieces as it arrives and is never held whole, so its size is bounded by your storage, not by your plan's memory. SET receives the namespace path it was written to.

Upload and Download
FTP ?conn UPLOAD "/root/build.zip" TO "/public_html/build.zip"
AFTER FTP ?conn DOWNLOAD "/logs/access.log" INTO "/mounted/logs/access.log" SET ?path
AFTER EMIT ?path
A Large Transfer, Without Blocking
FTP ?conn DOWNLOAD "/pub/nightly.tar" INTO "/mounted/nightly.tar" SET PROMISE ?job
AFTER EMIT "transfer started, script keeps running"
AFTER WAIT FOR ?job SET ?path
AFTER EMIT ?path

Organising

Create, Move, Delete
FTP ?conn MKDIR "/public_html/assets"
AFTER FTP ?conn MOVE "/public_html/old.html" TO "/public_html/archive/old.html"
AFTER FTP ?conn DELETE "/public_html/tmp.txt"
AFTER FTP ?conn DELETE "/public_html/old-release"
(* A folder is deleted with everything inside it *)

DELETE takes a file or a folder. A folder's contents are removed first, each subfolder in the same way, and then the folder itself, so one statement removes a whole tree. Over SFTP a link inside the folder is removed as a link and never followed, so nothing outside the folder is touched. FTP has no command that copies a file on the server; to duplicate one, DOWNLOAD it and UPLOAD it under the new name, or use TRANSFER to place it on another server.

External Storage — /external

Everything above treats a server as somewhere to send files to and fetch files from. EXTERNAL BIND does something different: it mounts the connection as a real location in your namespace. After binding, /external sits alongside /root and /mounted and every OcaltQL verb that takes a path reaches it - not a subset, all of them.

Bind a Connection, Then Use Ordinary File Verbs
FTP CONNECT "example.ocalt.com" AS "user" WITH "password" SET ?conn
AFTER EXTERNAL BIND FTP ?conn SET ?status
AFTER FILE READ "/external/file.txt" SET ?txtcontent
AFTER EMIT ?txtcontent

Nothing on that third line is FTP-aware. FILE READ does not know or care that the bytes come off a remote server - the path is what routes it. The same is true of every other verb:

The Whole Verb Surface, Against a Remote Server
FTP CONNECT "example.ocalt.com" AS "user" WITH "password" SET ?conn
AFTER EXTERNAL BIND FTP ?conn SET ?status

AFTER FILE LIST "/external/reports" SET ?items
AFTER FILE WRITE "generated" TO "/external/reports/out.txt"
AFTER FILE COPY "/root/local.pdf" TO "/external/archive/local.pdf"
AFTER FILE MOVE "/external/old.csv" TO "/mounted/old.csv"
AFTER FILE STAT "/external/reports/out.txt" SET ?info
AFTER FILE DELETE "/external/tmp.txt"
Beyond FILE - Any Verb That Takes a Path
EDITOR OPEN "/external/app.js" SET ?doc
AFTER EDITOR ?doc REPLACE ALL "var " WITH "let " SET ?doc
AFTER EDITOR ?doc SAVE SET ?ok

AFTER IMAGE LOAD "/external/photos/raw.jpg" SET ?img
AFTER GRAPHIC ?img RESIZE WIDTH 800 SET ?img

AFTER MEDIA LOAD "/external/media/clip.mp4" SET ?media
AFTER MEDIA ?media TRIM FROM "00:00:10" TO "00:00:40" SET ?clip

AFTER FILE COMPRESS ZIP [FILE SELECT ALL "/external/reports"] TO "/root/reports.zip"
The bind is per execution, and the last one holds. /external names exactly one connection at a time. Bind twice in the same script and the second replaces the first - every path used after that point resolves against the new server, and paths used before it resolved against the old one. Nothing is stored: a later execution starts with nothing bound and must bind again. Inside a PERSIST timeline the bind holds for the life of the timeline, like any other execution state.

EXTERNAL UNBIND

Releases the mount, so /external stops resolving. A bind lasts only for the execution that made it, so this is for the case where you want it gone before the script ends - freeing the path for a second bind, or closing a connection early.

Bind, Use, Release
FTP CONNECT "example.ocalt.com" AS "user" WITH "pass" SET ?c
AFTER EXTERNAL BIND FTP ?c
AFTER FILE COPY "/external/report.pdf" TO "/root/report.pdf"
AFTER EXTERNAL UNBIND
Two Servers in One Script
FTP CONNECT "source.your-host.com" AS "u1" WITH "p1" SET ?a
AFTER FTP CONNECT "backup.your-host.com" AS "u2" WITH "p2" SET ?b

AFTER EXTERNAL BIND FTP ?a SET ?status
AFTER FILE COPY "/external/report.csv" TO "/root/from-a.csv"

AFTER EXTERNAL BIND FTP ?b SET ?status
AFTER FILE COPY "/root/from-a.csv" TO "/external/incoming/report.csv"
(* The second bind replaces the first — /external is the backup host from that line on *)
Moving a file between two servers does not require binding at all - FTP ?src TRANSFER ... TO ?dst below does it directly, and does not touch /external.
Latency is real and the path hides it. A read from /external crosses a network; a read from /root does not. The verbs are identical, the cost is not - a loop that reads a hundred files from /external makes a hundred round trips. Pull what you need to /root once with FILE COPY when you are going to work on it repeatedly.

Server to Server

Two open connections can pass a file directly between them, without the bytes travelling through your namespace at all. TRANSFER is non-blocking - it returns a status anchor immediately, read with FTP STATUS, the same shape DIRECTIVE NETWORK TRANSFER uses between machines.

Move a File Between Two Servers
FTP CONNECT "source.your-host.com" AS "u1" WITH "p1" SET ?src
AFTER FTP CONNECT "backup.your-host.com" AS "u2" WITH "p2" SECURE SET ?dst
AFTER FTP ?src TRANSFER "/exports/data.zip" TO ?dst "/imports/data.zip" SET ?anchor
AFTER FTP STATUS ?anchor SET ?progress
AFTER IF ?progress IS IDENTICAL TO "done"
OPEN
  EMIT "server-to-server transfer complete"
CLOSE
OR
OPEN
  EMIT "still transferring — check back later"
CLOSE
FTP STATUS takes the anchor, not a connection. A server-to-server transfer belongs to neither endpoint, so its progress is read from the anchor itself - the same reason DIRECTIVE STATUS ?anchor is distinct from DIRECTIVE STATUS "id".
A transfer holds both of its connections. The bytes move between the two servers directly, so both ?src and ?dst are in use for as long as it takes. A later statement that touches either one waits for the transfer to finish - which is not what non-blocking means to a script that expected to keep working. Open a separate connection for anything you want to do meanwhile; the two are independent, and nothing stops you holding several at once.

Closing

Close a Connection
FTP ?conn CLOSE

Full Verb Reference

Verb Description
FTP CONNECT "host" [ON "port"] AS "user" WITH "password" [SECURE] SET ?connConnect by password - FTP, or FTPS with SECURE
FTP CONNECT "host" AS "user" KEY "/root/key" SECURE SET ?connConnect by key - SFTP. Every verb below works over it; the server's host key is recorded on first contact and must match after
FTP ?conn LIST "path" SET ?itemsDirectory listing - name, is_dir, size, modified
FTP ?conn STAT "path" SET ?infoMetadata for one path
FTP ?conn UPLOAD "/root/src" TO "/remote/dst"Namespace to server
FTP ?conn DOWNLOAD "/pub" INTO "/root/dst" SET ?pathServer to namespace, streamed - accepts SET PROMISE
FTP ?conn MKDIR "path"Create a directory
FTP ?conn MOVE "from" TO "to"Move or rename
FTP ?conn DELETE "path"Delete a file, or a folder with everything in it
FTP ?src TRANSFER "path" TO ?dst "path" SET ?anchorServer to server, non-blocking
FTP STATUS ?anchor SET ?progressProgress of a server-to-server transfer
EXTERNAL BIND FTP ?conn SET ?statusMount the connection as /external - every path-taking verb reaches it
FTP ?conn CLOSEClose the connection

Your Namespace as an FTP Server

Everything above connects OcaltQL out to a file server. This is the other direction: your own namespace, reachable over FTP, from FileZilla or any other client.

Minting Access
NEW FTP ACCESS TO "/root" SET ?access
AFTER EMIT ?access("host") & ":" & ?access("port")
AFTER EMIT ?access("username")
AFTER EMIT ?access("password")
Scoping It to a Folder
NEW FTP ACCESS TO "/mounted/media" SET ?access
(* The client sees that folder as its root and cannot climb above it *)

AFTER FTP ACCESS LIST SET ?all
AFTER FTP ACCESS REVOKE ?access("id")
Access is granted to /root or /mounted, or to any folder inside them. The client is confined to whatever was granted - it is the same namespace boundary every OcaltQL file verb obeys, enforced at the FTP layer. /mounted requires Starter or above, since Free has no mounted storage.
Credentials are minted per grant and revocable. Revoking drops any connected session immediately. They are not your Ocalt account password and cannot be used to sign in anywhere else.