Importing from the Registry
Someone declared a UDF. You want to use it. This page is the read direction — discover, pin, pull the source, run it — and, just as importantly, the four places where that path stops short of what you would expect.
The write direction is The Registry.
The import primitive is three GETs
That is the whole of it. There is no import endpoint, no bundle format, and nothing to install.
1 · Discover, or resolve by name.
The list is paginated and filterable by ?kind= and ?module=. Either way you
get the head, which carries latestVersion — a sha256 hex string, or null
if nobody has ever pushed a version.
2 · Read the history.
Newest first, paginated, and without source — the list projection drops it
deliberately, because 200 rows of source is megabytes.
3 · Pull one version, with its source.
This is the only route in the entire collection that ever serves source.
It also carries signature — { params, returns, doc } — which is what tells
you how to call the thing.
Three ways to do it
With curl
Two round trips, and there is no way to make it one. See
no latest, no alias, no prefix below.
With the CLI
runnable import is a composition of the GETs above, not a new capability. It
writes the source, signature.json, and a PINNED.json recording
{ remote, namespace, name, version } — because a pin that lives only in shell
history is not a pin.
With GraphQL
Every write goes through REST, which owns the validation — the channel-secret rejection, the host-key derivation, the 409 on a duplicate. GraphQL here is a dashboard read surface and carries no mutations at all.
RunnableVersion.source is deliberately absent from the GraphQL type:
"Full source is megabytes and GraphQL here has no per-field cost control", so
a naive runnableVersions { source } over a busy namespace would be an
unbounded response. Use the REST version-detail route for source.
One more difference, and it is intentional: GraphQL reports NOT_FOUND both
for "no such namespace" and for "you cannot read it", so existence never leaks.
REST 404s the first and 403s the second.
What is missing
This is the useful part of the page. Four gaps, all real, all things a reader will otherwise discover the hard way.
No latest, no alias, no tag, no prefix
The version path parameter takes the full 64-character lowercase hex and
nothing else. There is no ?version=latest, no named alias, no tag, no semver
range, and no short-hash or prefix match. 9f2c1b is a 404; only the whole
digest resolves.
The one symbolic handle that exists is the latestVersion column on the
head, which you must read and echo back. So:
Pinning is: read
latestVersiononce, write the hex down, and pass it forever. Import-by-name-alone is two round trips, not one, and every "latest" in your tooling is a value you resolved at some point in the past and are now responsible for.
That is not as bad as it sounds — an immutable content hash is exactly what you want in a lockfile — but it does mean the registry cannot answer "give me the current one" in a single call, and any tool that pretends otherwise is caching the answer somewhere.
No join
No route returns a Runnable together with its RunConfig. There is no
?include=, no ?expand=, and no embedded object anywhere in the responses. A
RunConfig is referenced by name from elsewhere, on purpose — the control
plane resolves late, and Invocation.runConfigRef is a name for the same reason.
An importer that wants "the UDF and the environment it runs in" makes two or three calls and correlates by hand:
Step 3 is a convention, not an API guarantee. There is no column on Runnable
that points at a RunConfig.
DreamLake does not run anything
declarations.ts opens by saying so, and closes by repeating it:
Storage + CRUD only … Nothing here schedules, claims, launches or connects to anything.
There is no POST /run, no /invoke, no queue and no worker in the declaration
routes. Execution reaches the control plane through exactly one door, and it is
an opaque proxy:
It forwards the wildcard path verbatim to a registered Lakeshore's base URL
with a decrypted stored bearer token injected, requires lakeshore:read for
GET and lakeshore:use for anything mutating, answers 502 when the
upstream is unreachable, and streams SSE straight back. It is deliberately a
dumb single-target forwarder so it cannot be pointed anywhere but that
Lakeshore's control plane.
The proxy requires a registered Lakeshore row holding an encrypted control
plane token. If you are evaluating DreamLake with no cloud account and no
running control plane, that door is closed to you and no amount of registry
access opens it.
So the honest "…and run it" story for a reader with no infrastructure is: import the source, then run it locally, through the SDK, against an in-memory dispatch.
The queue name has to match the one the imported UDF declared — routing is
caller-side and baked into the decorator. If the UDF you imported has no
queue=, it is local by default and a plain call runs it directly, with no
dispatch and no worker at all.
The starter kit's 05_import_from_registry directory does exactly this
end to end: resolve, pin, pull, write PINNED.json, and run the pulled source
locally.
The registry cannot say what shape a body is
Runnable.kind is a lifetime discriminator — udf, session, agent —
and nothing more. There is no field on Runnable and no field on
RunnableVersion that records whether the body is sync, async, a generator or
an async generator.
That matters because the four shapes are not interchangeable at the call site. A
generator's results arrive as a stream of frames; a sync UDF's arrive as one
value. An importer that guesses wrong writes the wrong caller. Today the only
ways to find out are to read signature, if the publisher put something useful
there, or to read the source.
RunnableVersion.signature is free JSON whose shape is explicitly owned by
the SDK, not by the server. So a key like:
would let the registry answer "is this a streaming UDF?" with no schema
change, no migration, and no new column — the cheapest honest fix available.
The starter kit already stamps signature.form as a convention and labels it
as one.
The alternative — a real nullable column on RunnableVersion, which would let
the dashboard badge it and let a list query filter on it — is a genuine
improvement and a genuine migration. It has not been decided.
Importing an agent
An agent imports through the same three GETs, because an agent is a
Runnable — one with kind: "agent" and two extra columns on the head:
| Field | What it is |
|---|---|
description | One-line documentation. What it is for. |
markdown | The agent's instructions. Behaviour, not documentation. |
channel | { kind, target?, config? } — where the pipe attaches. A stored declaration; never a live connection. |
markdown and channel come back on the head response, in plaintext — there
are no encrypted columns on these models, and no read-path stripping. That is
also why channel is credential-scanned on the way in; see
the registry page.
This is a genuine gap and it is worth being blunt about. markdown and
channel live on the mutable head, so they are whatever they are right
now. The version hash covers the source and only the source.
Pin agents.triage.triage_agent at 9f2c1b… and you have pinned its code.
Someone editing the agent's instructions an hour later changes what your
"pinned" agent does, and nothing in your lockfile, your diff or your import
output will show it. There is no history to compare against either — see
the split tripwire.
If that matters to you today, copy markdown into your own repo at import time
and treat drift from it as a review item.
What you get back
The version-detail route — the only one that serves source:
Notes on the shape, since two of them catch people:
sourceis nullable. A version may be registered without one. Check before you write a file.signatureis always an object, defaulting to{}— nevernull. Its contents are the SDK's business, not the server's, so treat every key inside it as optional.metadataisnullwhen unset, not{}.- The list projection (
GET …/versions) returns every field above exceptsource, enveloped as{ "versions": [...], "page", "pageSize", "total", "totalPages" }. - Errors are always
{ "error": "…" }.