AgentDB with Swift
Use AgentDB as the database of your Swift app. This page shows every call you need, with URLSession with async/await. No packages.
You can copy each block into your own code. Together they form one program that our tests run.
This page is tested locally on macOS with npm run docs:examples. It is not a required CI check: CI runs it only when the runner has Swift.
Server-side only. Use this on a server (Vapor, Hummingbird) or in your own tools. Do not ship an AgentDB key inside an iOS or macOS app: anyone can extract it. An app calls your backend instead; see pattern B.
#Before you start
- Swift 5.9 or newer, on macOS or Linux.
- A workspace with the example schema:
contact,companyandworks_at. - Three environment variables on your server:
AGENTDB_URL,AGENTDB_API_KEYandAGENTDB_WORKSPACE. See Environment variables.
#Connect
Read the settings from the environment. The key never appears in the code.
let env = ProcessInfo.processInfo.environment
var baseUrl = env["AGENTDB_URL"] ?? "https://app.agentdb.link"
while baseUrl.hasSuffix("/") { baseUrl.removeLast() }
let apiKey = env["AGENTDB_API_KEY"] ?? "" // a server-side secret: never in app or browser code
let workspace = env["AGENTDB_WORKSPACE"] ?? "" // ws_...
if apiKey.isEmpty || workspace.isEmpty { fatalError("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") }
let api = "\(baseUrl)/v1/workspaces/\(workspace)"Every call goes through one small helper. It sends the key in the Authorization: Bearer header, turns AgentDB errors into one error type, and retries when that is safe.
/// A JSON object from the API, read with keys: record["data"]["name"].
typealias JSON = [String: Any]
/// An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}.
struct AgentDBError: Error, CustomStringConvertible {
let status: Int
let code: String
let message: String
let retryable: Bool
let details: JSON
init(status: Int, error: JSON?) {
self.status = status
code = error?["code"] as? String ?? "HTTP_ERROR"
message = error?["message"] as? String ?? ""
retryable = error?["retryable"] as? Bool ?? false
details = error?["details"] as? JSON ?? [:]
}
var description: String { "\(status) \(code): \(message)" }
}
/// One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After
/// (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write
/// with an Idempotency-Key). Returns the raw body; call() below returns its "data" part.
func send(_ method: String, _ path: String, json: Any? = nil, body: Data? = nil, idempotencyKey: String? = nil) async throws -> Data {
var req = URLRequest(url: URL(string: api + path)!)
req.httpMethod = method
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
if let json {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: json)
}
if let body {
req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
req.httpBody = body
}
if let idempotencyKey { req.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key") }
let safeToRepeat = method == "GET" || idempotencyKey != nil
var attempt = 0
while true {
let data: Data, res: HTTPURLResponse
do {
let (d, r) = try await URLSession.shared.data(for: req)
(data, res) = (d, r as! HTTPURLResponse)
} catch where error is URLError && safeToRepeat && attempt < 4 {
try await Task.sleep(nanoseconds: UInt64(500 << attempt) * 1_000_000)
attempt += 1
continue
}
if (res.statusCode == 429 || res.statusCode == 503) && attempt < 4 {
let retryAfter = Double(res.value(forHTTPHeaderField: "Retry-After") ?? "") ?? 0
let seconds = retryAfter > 0 ? retryAfter : Double(500 << attempt) / 1000 + Double.random(in: 0..<0.25)
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
attempt += 1
continue
}
if !(200..<300).contains(res.statusCode) {
let envelope = (try? JSONSerialization.jsonObject(with: data)) as? JSON
throw AgentDBError(status: res.statusCode, error: envelope?["error"] as? JSON)
}
return data
}
}
/// One API call that answers with JSON. Returns the "data" part.
func call(_ method: String, _ path: String, json: Any? = nil, idempotencyKey: String? = nil) async throws -> JSON {
let data = try await send(method, path, json: json, idempotencyKey: idempotencyKey)
let envelope = try JSONSerialization.jsonObject(with: data) as? JSON
return envelope?["data"] as? JSON ?? [:]
}Check the connection by reading the schema:
let schema = try await call("GET", "/object-types")
let objects = schema["objects"] as? [JSON] ?? []
print("Objects:", objects.compactMap { $0["name"] as? String }.joined(separator: ", "))#Create a record
Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its id and version 1.
let ada = try await call("POST", "/records",
json: ["object": "contact", "data": ["name": "Ada Lovelace", "email": "ada-\(run)@example.com", "score": 72]],
idempotencyKey: UUID().uuidString) // safe to retry: the same key never writes twice
let adaId = ada["id"] as! String
print("Created", adaId, "version", ada["version"] ?? "")The Idempotency-Key header makes a retry safe. See Idempotency keys.
#Read a record
let record = try await call("GET", "/records/\(adaId)")
let fields = record["data"] as? JSON ?? [:]
print(fields["name"] ?? "", fields["email"] ?? "", "version", record["version"] ?? "")#Update a record
An update names the version you read. If someone changed the record since then, AgentDB refuses with VERSION_CONFLICT (HTTP 409) and writes nothing. Read it again, apply your change, and try again:
/// Read, change, write with the current version. On VERSION_CONFLICT: read again and retry.
func updateRecord(_ id: String, attempts: Int = 3, _ change: (JSON) -> JSON) async throws -> JSON {
var i = 1
while true {
let current = try await call("GET", "/records/\(id)")
do {
let data = change(current["data"] as? JSON ?? [:])
return try await call("PATCH", "/records/\(id)", json: ["version": current["version"] ?? 0, "data": data])
} catch let err as AgentDBError where err.code == "VERSION_CONFLICT" && i < attempts {
i += 1 // someone else changed the record: read it again
}
}
}
let updated = try await updateRecord(adaId) { data in ["score": (data["score"] as? Int ?? 0) + 10] }
let updatedData = updated["data"] as? JSON ?? [:]
print("Score", updatedData["score"] ?? "", "version", updated["version"] ?? "")This is what the conflict looks like:
do {
_ = try await call("PATCH", "/records/\(adaId)", json: ["version": 1, "data": ["score": 0]]) // stale version
} catch let err as AgentDBError where err.code == "VERSION_CONFLICT" {
print("Conflict: the record is now at version", err.details["current_version"] ?? "")
}#Query with filters, sort and pages
A query takes an object, a where filter, a sort and a limit (at most 200). Every condition in all must match. Use the in operator for "one of several values". Operators: eq, neq, in, lt, lte, gt, gte, is_null, is_not_null.
When more records match, the answer holds a next_cursor. Send it as cursor with the same query to get the next page. A next_cursor of null means you have them all.
/// Every record that matches, page by page (limit up to 200; follow next_cursor).
func queryAll(_ object: String, where filter: JSON, sort: [JSON], pageSize: Int = 2) async throws -> [JSON] {
var out: [JSON] = []
var cursor: String? = nil
repeat {
var body: JSON = ["object": object, "where": filter, "sort": sort, "limit": pageSize]
if let cursor { body["cursor"] = cursor }
let page = try await call("POST", "/query", json: body)
out += page["records"] as? [JSON] ?? []
cursor = page["next_cursor"] as? String // null on the last page
} while cursor != nil
return out
}
let good = try await queryAll(
"contact",
where: ["all": [["field": "score", "op": "gte", "value": 70], ["field": "email", "op": "neq", "value": "nobody@example.com"]]],
sort: [["field": "score", "order": "desc"]]
)
for c in good {
let data = c["data"] as? JSON ?? [:]
print(data["score"] ?? "", data["name"] ?? "")
}See Querying for text search, totals and saved views.
#Link records
A relationship type such as works_at links a contact to a company. Create a link, then list the records on the other side:
let company = try await call("POST", "/records", json: ["object": "company", "data": ["name": "Example Ltd \(run)"]])
let companyId = company["id"] as! String
_ = try await call("POST", "/links",
json: ["relationship": "works_at", "source_record_id": adaId, "target_record_id": companyId],
idempotencyKey: "link-\(adaId)-\(companyId)")
let related = try await call("GET", "/records/\(adaId)/related?relationship=works_at")["records"] as? [JSON] ?? []
print("Ada works at", related.compactMap { ($0["data"] as? JSON)?["name"] as? String }.joined(separator: ", "))Add direction=incoming to list from the other side, for example all contacts of a company.
#Files
A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type.
let bytes = Data("Meeting notes for Ada (\(run))\n".utf8)
// 1. Announce the file, 2. send the raw bytes, 3. attach it to a record.
let file = try await call("POST", "/files", json: ["filename": "notes.txt", "content_type": "text/plain", "size": bytes.count])
let fileId = file["id"] as! String
_ = try await send("PUT", "/files/\(fileId)/content", body: bytes)
_ = try await call("POST", "/attachments", json: ["file_id": fileId, "record_id": adaId])
// Download with the key (server side) ...
let back = try await send("GET", "/files/\(fileId)/content")
// ... or make a signed link that expires, for an <img> or a download button in your page.
let link = try await call("POST", "/files/\(fileId)/links", json: ["expires_in_seconds": 600])
print("Signed link until", link["expires_at"] ?? "")To show a file in a browser, give the page the signed link, never your key. The link expires and can be revoked. See Files and imports.
#Errors
Every error has a stable code, a message, a retryable flag and details, plus an HTTP status. Act on the code.
do {
_ = try await call("GET", "/records/rec_doesnotexist")
} catch let err as AgentDBError { // a network error is a URLError, not an AgentDB answer
print(err.status, err.code, err.message) // 404 NOT_FOUND ...
if err.retryable { print("Safe to retry later") }
}URLSession throws URLError on network errors; the helper retries those only when the call is safe to repeat.
Common codes:
| Code | HTTP | What to do |
|---|---|---|
VALIDATION_ERROR, FIELD_TYPE_MISMATCH | 400 | Fix the request; details names the field. |
UNAUTHENTICATED | 401 | Check the key. |
FORBIDDEN | 403 | The key's role or a permission rule does not allow this. |
NOT_FOUND | 404 | Check the id and the workspace. |
VERSION_CONFLICT | 409 | Read again and retry the update. |
RATE_LIMITED | 429 | Wait for Retry-After, then retry. |
The error codes page lists them all.
#Rate limits and retries
AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers 429 RATE_LIMITED with a Retry-After header in seconds.
The request helper above already handles this:
- On 429 or 503, it waits for
Retry-After, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key.
- It gives up after a few attempts and reports the error.
To load many records, use one batch call instead of one call per record: POST /records/upsert takes up to 500 items. See records_upsert_batch.
#Idempotency keys
A timeout can leave you unsure whether a write happened. Send an Idempotency-Key header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days.
let key = UUID().uuidString // one key per intention; reuse it only to retry the same request
let request: JSON = ["object": "contact", "data": ["name": "Mary Somerville", "email": "mary-\(run)@example.com"]]
let first = try await call("POST", "/records", json: request, idempotencyKey: key)
let retry = try await call("POST", "/records", json: request, idempotencyKey: key) // e.g. after a timeout
print(first["id"] as? String == retry["id"] as? String ? "Same record, written once" : "Duplicate!")Use a new key for each new intention, such as each form submission. The same key with a different request fails with IDEMPOTENCY_KEY_REUSED.
#Run the full example
The complete program is examples/languages/swift/main.swift. Set the three environment variables and run:
swift main.swiftIt prints one OK line per topic: OK connect, OK create, OK read, OK update, OK query, OK relationships, OK files, OK errors and OK idempotency.