SDK Reference
One import. Every building block. Six languages. Zero dependencies.
The Drift SDK is what your Atomic functions import to talk to the platform. It exists in six languages — Go, Python, Node.js, Ruby, PHP, and Rust — and every one exposes the same API surface, follows the same wire protocol, and gives you the same local-development experience.
The interpreted SDKs (Python, Node.js, Ruby, PHP) use only their language's standard library. The Rust
SDK uses two crates (serde_json + ureq); the Go SDK uses only stdlib. No
transitive dependencies, no version conflicts, no supply chain risk from us.
go.mod, package.json, requirements.txt, and so on) and the CLI
resolves the latest release for you every time you deploy or run — nothing
to pin.
Writing a function
A Drift function is a named handler with an @atomic annotation. The CLI
reads the annotation and generates the program's entry point — you never write main()
or call run() yourself (Ruby is the one exception, and ends its file with
Drift.run(method(:handler))).
- Annotation —
@atomic http=post:itemsfor an HTTP route, or@atomic queue=namefor a queue consumer. Path params use:name. - Arguments —
(req)for a request with no body (GET);(body, req)when there's a request body or queue payload. - Return —
(status, message, payload), with an optional 4thheadersmap. - Request — read path params from
req.params, query fromreq.query, headers fromreq.headers, raw body fromreq.body. - Streaming — add
stream=sseorstream=wsand the handler receives an extraemit(SSE) orconn(WebSocket) argument.
// @atomic http=post:items auth=none
import drift "github.com/ondrift/sdk/go"
// Go only: the generated wrapper unmarshals the request body into a
// package-level type named RequestBody — declare it (a struct, or an
// alias of map[string]any). `drift atomic new` scaffolds one for you.
type RequestBody = map[string]any
func PostItems(body RequestBody, req drift.Request) (int, string, any, map[string]string) {
id, _ := drift.NoSQL.Collection("items").Insert(body)
return 201, "Created", map[string]any{"id": id}, nil
}
The handler's name is the method and route in your language's casing — http=post:items → PostItems — so the annotation does the routing and there's nothing to register. In Go, a POST handler's body parameter is a package-level RequestBody type the wrapper unmarshals into (use a struct to validate the shape, or type RequestBody = map[string]any for a loose map); GET handlers take just (req).
The request object
Every handler receives a req with the same fields in every language. The runner builds it
from the inbound HTTP request and passes it to your function as JSON:
| Field | Type | What it holds |
|---|---|---|
req.method | string | HTTP method — GET, POST… |
req.path | string | The request path, e.g. /api/items/42. |
req.params | map | Path params from the route's :name segments — http=get:items/:id → req.params["id"]. Extracted for you. |
req.query | string | Raw, URL-encoded query string ("status=open&limit=50") — not parsed. Split it with your language's URL library. |
req.headers | map | Request headers by name — req.headers["Authorization"]. |
req.body | any | The parsed body — a JSON object for application/json, or a form dict for multipart/form-data (below). Also handed to you as the first body argument. |
Path params are extracted for you; the query string is not. A request to
/api/orders?status=open arrives as req.query == "status=open" — parse it
with url.ParseQuery (Go), URLSearchParams (Node),
urllib.parse.parse_qs (Python), URI.decode_www_form (Ruby).
File uploads (multipart)
A multipart/form-data POST arrives as a body dict: text fields are strings, and
each uploaded file is an object with filename, content_type, and
data — the raw bytes (bytes in Python, Buffer in Node). No
parsing library needed.
# @atomic http=post:upload auth=none
def post_upload(body, req):
name = body["applicant_name"] # a form field (string)
f = body["attachment"] # {filename, content_type, data}
drift.blob.put(f"uploads/{f['filename']}", f["data"], f["content_type"])
return 201, "Stored", {"name": f["filename"]}
Backbone API
The seven Backbone primitives are reachable from every SDK. Shown here in Go and Python; the other languages mirror them in their own idiom.
| Primitive | Go | Python | Methods |
|---|---|---|---|
| Secrets | drift.Secret.Get(k) | drift.secret.get(k) | Get · Set · Delete |
| NoSQL | drift.NoSQL.Collection(c) | drift.nosql.collection(c) | Insert · Get · List · Delete · Drop |
| SQL | drift.SQL(name) | drift.sql(name) | Query · Execute · Begin |
| Cache | drift.Cache.Set(k, v, ttl) | drift.cache.set(k, v, ttl) | Set · Get · Del |
| Queues | drift.Queue(q).Push(m) | drift.queue(q).push(m) | Push (consume via a queue= worker) |
| Blobs | drift.Blob.Put(k, data, type) | drift.blob.put(k, data) | Put · Get |
| Locks | drift.Lock.Acquire(n, ttl) | drift.lock.acquire(n, ttl) | Acquire · Release |
Method names follow each language's conventions — Go's drift.Secret.Get is
drift.secret.get in the interpreted SDKs. In Node.js every Backbone call is async,
so await it. Realtime is the one primitive that isn't an SDK call —
clients subscribe over WebSocket at /realtime/<channel>; see the
Backbone guide.
Utilities
| Go | Python | Description |
|---|---|---|
drift.JWT.Issue(claims) | drift.jwt.issue(claims) | Mint a JWT signed with your slice's key |
drift.JWT.Verify(tok, opts) | drift.jwt.verify(tok) | Verify and decode a JWT |
drift.Log(msg) | drift.log(msg) | Structured log, captured by the runner |
drift.HTTPRequest(m, url, h, b) | drift.http_request(m, url) | Outbound HTTP (egress-gated, 30s default timeout) |
drift.Env(key) | os.environ[key] | Read an environment variable (secrets arrive as env vars) |
Return format
Every handler returns three values, plus an optional fourth:
| Position | Type | Description |
|---|---|---|
| 1 | integer | HTTP status code (200, 201, 400, 500…) |
| 2 | string | Short status message — "OK", "Created", "Bad Request" |
| 3 | any | Response body (objects and maps are serialised to JSON for you) |
| 4 (optional) | map | Extra response headers |
It's a tuple in Go, Python, and Rust; an array in Node.js and PHP; a hash (status / message / payload keys) in Ruby.
{"status":…, "message":…, "payload":…} — not the
bare payload. So a browser calling /api/<route> must read .payload
(e.g. const data = (await res.json()).payload). The tuple's status also becomes the HTTP
status code. The one exception: if your handler sets a non-JSON Content-Type, the payload
is sent as the raw body (base64-decoded), with no envelope — that's how you return files or HTML.
Type reference
The tables above name the calls; this is their exact surface — signatures and return shapes, the part you can't guess and can't ship wrong. Signatures are shown in Go (the statically-typed reference). The dynamically-typed SDKs (Python, Node.js, Ruby, PHP) hand you already-parsed values — a dict or array, not raw bytes — so the “decode the bytes” / “unmarshal each” notes apply to Go and Rust; elsewhere the value arrives ready to use.
The handler & request
The CLI generates main() and calls the SDK's Run for you — you
never call Run / run() yourself (Ruby ends its file with
Drift.run(method(:handler)), the one exception). A queue worker has the
same shape as an HTTP handler; the queue message is delivered as the first body argument.
// HTTP handler — body present (POST/PUT) or omit it (GET): func GetX(req drift.Request)
func PostItems(body map[string]any, req drift.Request) (int, string, any, map[string]string)
// Queue worker — same shape; the message is `body`. Trigger: // @atomic queue=validate
func Validate(body map[string]any, req drift.Request) (int, string, any, map[string]string)
type Request struct {
// No Method field: you were routed here AS the post:items handler,
// so there's nothing to branch on. get:x and post:x are two functions.
Path string
Headers map[string]string
Query string // RAW query string — parse it yourself
Body json.RawMessage // also handed to you as the `body` arg
Params map[string]string // :name path params, extracted for you
}
NoSQL — drift.NoSQL.Collection(name)
Insert(doc any) (string, error) // returns a STORAGE KEY, not your _id
Get(id string) (json.RawMessage, error) // looks up the doc's _id FIELD
List(filter map[string]string) ([]json.RawMessage, error) // one doc per element — unmarshal each
Delete(key string) error
Drop() error
Two traps worth stating plainly: Insert returns an internal storage key, not the
_id — to read a document back by id, set your own _id on the document and
pass it to Get. And List is equality-only: the filter matches
fields exactly, with no ordering, ranges, or pagination. When you need
WHERE seq > 100 ORDER BY seq — op-logs, feeds, cursors — reach for SQL.
SQL — drift.SQL(name)
Query(sql string, args ...any) ([]map[string]any, error) // SELECT → rows as maps
Execute(sql string, args ...any) (SQLResult, error) // INSERT/UPDATE/DELETE + DDL
Begin() (SQLTx, error)
type SQLResult struct { RowsAffected, LastInsertID int64 }
type SQLTx struct { ... } // Query · Execute · Commit() · Rollback()
Execute runs DDL too (CREATE TABLE…). Inside a transaction, an idle
SQLTx is auto-rolled-back after 30s — commit or roll back promptly.
Arguments are positional placeholders (?), bound safely — never string-concatenate SQL.
Cache — drift.Cache
Get(key string) ([]byte, error) // RAW bytes — decode yourself
Set(key string, value any, ttlSeconds int) error
Del(key string) error
Utilities — drift.JWT
Issue(claims JWTClaims) (string, error) // Exp REQUIRED; Iat/Iss/Jti auto-set if zero
Verify(token string, opts JWTVerifyOptions) (JWTClaims, error) // pass JWTVerifyOptions{}, NOT nil
type JWTClaims struct {
Sub string
Iat int64
Exp int64
Nbf int64
Iss string
Aud []string
Jti string
Custom map[string]any // your app claims live HERE, e.g. claims.Custom["role"]
}
type JWTVerifyOptions struct { Audience, AllowedIssuer string }
Tokens are signed with your slice's own key, so no key management. Three things bite if you guess:
Exp is required on Issue (a missing or past expiry errors); app-defined fields go
in Custom (a map), not at the top level; and Verify enforces the issuer
is your slice by default — tokens minted elsewhere fail unless you set
AllowedIssuer. See Authentication for the full login→verify flow.
Secrets · Blobs · Locks · Streaming
// drift.Secret
Get(name string) (string, error) · Set(name, value string) error · Delete(name string) error
// drift.Blob
Put(name string, data []byte, contentType string) error · Get(name string) ([]byte, error)
// drift.Lock — Acquire returns a token you pass back to Release
Acquire(name string, ttlSeconds int) (string, error) · Release(name, token string) error
// drift.Queue(name) — producer side; consume with a queue= worker (above)
Push(body any) error
// Streaming args (stream=sse → emit, stream=ws → conn)
emit.Send(event string, data any) · conn.ReadJSON(target any) bool · conn.Write(data any)
Languages & conventions
| Language | Handler casing | Example | Dependencies |
|---|---|---|---|
| Go | PascalCase | PostItems | stdlib only |
| Python 3.9+ | snake_case | post_items | stdlib only |
| Node.js 18+ | camelCase | postItems | built-in APIs only |
| Ruby 3.0+ | snake_case | post_items | stdlib only |
| PHP 8.1+ | snake_case | post_items | built-in functions only |
| Rust | snake_case | post_items | serde_json + ureq |
The same handler, six ways
A GET that reads a value from cache, in every language — note the return is always (status, message, payload):
Go
// @atomic http=get:menu auth=none
func GetMenu(req drift.Request) (int, string, any, map[string]string) {
menu, _ := drift.Cache.Get("menu")
return 200, "OK", menu, nil
}
Python
# @atomic http=get:menu auth=none
def get_menu(req):
menu = drift.cache.get("menu")
return 200, "OK", menu
Node.js
// @atomic http=get:menu auth=none
async function getMenu(req) {
const menu = await drift.cache.get("menu");
return [200, "OK", menu];
}
module.exports = { getMenu };
Ruby
# @atomic http=get:menu auth=none
def get_menu(req)
menu = Drift::Cache.get("menu")
{ "status" => 200, "message" => "OK", "payload" => menu }
end
Drift.run(method(:get_menu))
PHP
// @atomic http=get:menu auth=none
function get_menu($req) {
$menu = \Drift\Cache::get("menu");
return [200, "OK", $menu];
}
Rust
// @atomic http=get:menu auth=none
pub fn get_menu(_req: Value) -> (i64, &'static str, Value) {
let menu = drift_sdk::cache::get("menu");
(200, "OK", menu)
}
Packaging & dependencies
A function is a folder, and the folder is a single package — multiple source
files work fine, and the CLI scans every file in the folder for an @atomic
annotation. Helpers without an annotation are just ordinary package code.
-
The CLI manages the manifest — you don't. On
deploy(and ondrift atomic fetchfor offline work) the CLI generates the per-folder manifest, references the SDK unversioned at its latest tag, and resolves it. For Go that's a generatedgo.mod(moduleatomic/<name>) plusgo get github.com/ondrift/sdk@latestandgo mod tidy. Import the SDK asgithub.com/ondrift/sdk/goand leave the version to the CLI — don't pin one yourself. -
Third-party dependencies work. Because the CLI runs your language's own resolver
(
go mod tidy,npm,pip,bundler, …), your code is not limited to the standard library — add a normal import and it's fetched at deploy. Use a real password-hashing library, a real validation crate, whatever you need. The zero-dependency rule applies to the SDK itself (our promise to you), not to your functions. - Sharing code across functions. There is no implicit sharing between function folders — each folder is its own deploy unit and sibling folders aren't on the import path. For shared logic, either keep it inside the folder (multi-file) or publish it as a normal module/package and import it like any other dependency.
Local development
drift atomic run serves your function locally over HTTP, backed by an
in-memory Backbone — NoSQL, cache, queues, blobs, locks, and secrets
all work with no platform running, so you can build and test most of your app entirely offline.
SQL is the one exception. drift.SQL(…) calls aren't served by the
local harness — the zero-dependency SDKs don't bundle a SQL engine — so a function that uses
SQL needs a deployed slice to exercise that path. NoSQL covers most local data needs;
reach for SQL where you genuinely need relational queries, and test it against a real slice.
In-memory state is wiped when the process stops, secrets are read from your .env file, and
outbound HTTP requests hit real endpoints.
Source code
All six SDKs are open source at github.com/ondrift/sdk — one repository, one release tag for all languages.