Version: closed alpha

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.

You don't install the SDK by hand. Reference it unversioned in your function's manifest (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))).

// @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:itemsPostItems — 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:

FieldTypeWhat it holds
req.methodstringHTTP method — GET, POST
req.pathstringThe request path, e.g. /api/items/42.
req.paramsmapPath params from the route's :name segments — http=get:items/:idreq.params["id"]. Extracted for you.
req.querystringRaw, URL-encoded query string ("status=open&limit=50") — not parsed. Split it with your language's URL library.
req.headersmapRequest headers by name — req.headers["Authorization"].
req.bodyanyThe 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.

PrimitiveGoPythonMethods
Secretsdrift.Secret.Get(k)drift.secret.get(k)Get · Set · Delete
NoSQLdrift.NoSQL.Collection(c)drift.nosql.collection(c)Insert · Get · List · Delete · Drop
SQLdrift.SQL(name)drift.sql(name)Query · Execute · Begin
Cachedrift.Cache.Set(k, v, ttl)drift.cache.set(k, v, ttl)Set · Get · Del
Queuesdrift.Queue(q).Push(m)drift.queue(q).push(m)Push (consume via a queue= worker)
Blobsdrift.Blob.Put(k, data, type)drift.blob.put(k, data)Put · Get
Locksdrift.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

GoPythonDescription
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:

PositionTypeDescription
1integerHTTP status code (200, 201, 400, 500…)
2stringShort status message — "OK", "Created", "Bad Request"
3anyResponse body (objects and maps are serialised to JSON for you)
4 (optional)mapExtra 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.

On the wire, the response body is an envelope. For a JSON response the client receives {"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
}

NoSQLdrift.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.

SQLdrift.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.

Cachedrift.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

LanguageHandler casingExampleDependencies
GoPascalCasePostItemsstdlib only
Python 3.9+snake_casepost_itemsstdlib only
Node.js 18+camelCasepostItemsbuilt-in APIs only
Ruby 3.0+snake_casepost_itemsstdlib only
PHP 8.1+snake_casepost_itemsbuilt-in functions only
Rustsnake_casepost_itemsserde_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.

Local development

drift atomic run serves your function locally over HTTP, backed by an in-memory BackboneNoSQL, 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.