AgentDB with Kotlin
Use AgentDB as the database of your Kotlin app. This page shows every call you need, with java.net.http.HttpClient from the JDK. No libraries. The example carries a small JSON helper; in an application, use kotlinx.serialization, Jackson or Moshi. Ktor and OkHttp clients work the same way.
You can copy each block into your own code. Together they form one program that our tests run.
Not tested in CI as a required check: CI runs this code only when the runner has the Kotlin compiler. The same calls are tested in Java, which uses the same HTTP client.
Server-side only. Use this on a server (Ktor, Spring Boot). Do not ship an AgentDB key inside an Android app: anyone can extract it. The app calls your backend instead; see pattern B.
#Before you start
- Kotlin 1.9 or newer on Java 21.
- 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.
val BASE_URL = (System.getenv("AGENTDB_URL") ?: "https://app.agentdb.link").trimEnd('/')
val API_KEY: String = System.getenv("AGENTDB_API_KEY") ?: error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") // a server-side secret: never in browser code
val WORKSPACE: String = System.getenv("AGENTDB_WORKSPACE") ?: error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") // ws_...
val API = "$BASE_URL/v1/workspaces/$WORKSPACE"
val HTTP: HttpClient = HttpClient.newHttpClient()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.
/** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */
class AgentDBError(val status: Int, error: Map<String, Any?>) :
Exception("$status ${error["code"] ?: "HTTP_ERROR"}: ${error["message"] ?: ""}") {
val code = error["code"] as? String ?: "HTTP_ERROR"
val retryable = error["retryable"] == true
val details = error["details"] as? Map<*, *> ?: emptyMap<String, Any?>()
}
/**
* 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). Sends json as application/json, or body as raw bytes. Returns the raw response.
*/
fun send(method: String, path: String, json: Any? = null, body: ByteArray? = null, idempotencyKey: String? = null): HttpResponse<ByteArray> {
val safeToRepeat = method == "GET" || idempotencyKey != null
for (attempt in 0..4) {
val backoff = 500L shl attempt
val request = HttpRequest.newBuilder(URI.create(API + path)).header("Authorization", "Bearer $API_KEY")
when {
json != null -> request.header("Content-Type", "application/json").method(method, BodyPublishers.ofString(Json.write(json)))
body != null -> request.header("Content-Type", "application/octet-stream").method(method, BodyPublishers.ofByteArray(body))
else -> request.method(method, BodyPublishers.noBody())
}
if (idempotencyKey != null) request.header("Idempotency-Key", idempotencyKey)
val res = try {
HTTP.send(request.build(), BodyHandlers.ofByteArray())
} catch (e: IOException) {
if (safeToRepeat && attempt < 4) {
Thread.sleep(backoff)
continue
}
throw e
}
val status = res.statusCode()
if ((status == 429 || status == 503) && attempt < 4) {
val seconds = res.headers().firstValue("Retry-After").orElse("").toLongOrNull() ?: 0
Thread.sleep(if (seconds > 0) seconds * 1000 else backoff + (Math.random() * 250).toLong())
continue
}
if (status !in 200..299) {
val error = runCatching { Json.parse(res.body().decodeToString()).obj()["error"].obj() }.getOrNull()
throw AgentDBError(status, error ?: emptyMap()) // no error envelope: keep the defaults
}
return res
}
error("unreachable: the last attempt always returns or throws")
}
/** One API call that returns the "data" part of the answer. */
fun api(method: String, path: String, json: Any? = null, idempotencyKey: String? = null): Map<String, Any?> =
Json.parse(send(method, path, json, idempotencyKey = idempotencyKey).body().decodeToString()).obj()["data"].obj()
/** Casts for the parsed JSON: an object is a Map, an array is a List. */
@Suppress("UNCHECKED_CAST")
fun Any?.obj() = this as Map<String, Any?>
@Suppress("UNCHECKED_CAST")
fun Any?.list() = this as List<Map<String, Any?>>Check the connection by reading the schema:
val schema = api("GET", "/object-types")
val names = schema["objects"].list().map { it["name"] as String }
println("Objects: ${names.joinToString(", ")}")#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.
val ada = api(
"POST", "/records",
mapOf("object" to "contact", "data" to mapOf("name" to "Ada Lovelace", "email" to "ada-$run@example.com", "score" to 72)),
idempotencyKey = UUID.randomUUID().toString(), // safe to retry: the same key never writes twice
)
val adaId = ada["id"] as String
println("Created $adaId version ${ada["version"]}")The Idempotency-Key header makes a retry safe. See Idempotency keys.
#Read a record
val record = api("GET", "/records/$adaId")
val data = record["data"].obj()
println("${data["name"]} ${data["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. */
fun updateRecord(id: String, attempts: Int = 3, change: (Map<String, Any?>) -> Map<String, Any?>): Map<String, Any?> {
for (i in 1..attempts) {
val current = api("GET", "/records/$id")
try {
return api("PATCH", "/records/$id", mapOf("version" to current["version"], "data" to change(current["data"].obj())))
} catch (e: AgentDBError) {
if (e.code != "VERSION_CONFLICT" || i == attempts) throw e
}
}
error("unreachable: the last attempt always returns or throws")
}
val updated = updateRecord(adaId) { data -> mapOf("score" to (data["score"] as Long) + 10) }
println("Score ${updated["data"].obj()["score"]} version ${updated["version"]}")This is what the conflict looks like:
try {
api("PATCH", "/records/$adaId", mapOf("version" to 1, "data" to mapOf("score" to 0))) // stale version
} catch (e: AgentDBError) {
if (e.code != "VERSION_CONFLICT") throw e
println("Conflict: the record is now at version ${e.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). */
fun queryAll(objectType: String, where: Any, sort: Any, pageSize: Int = 2): List<Map<String, Any?>> {
val out = mutableListOf<Map<String, Any?>>()
var cursor: String? = null
do {
val query = mutableMapOf("object" to objectType, "where" to where, "sort" to sort, "limit" to pageSize)
if (cursor != null) query["cursor"] = cursor
val page = api("POST", "/query", query)
out += page["records"].list()
cursor = page["next_cursor"] as String? // null on the last page
} while (cursor != null)
return out
}
val good = queryAll(
"contact",
mapOf("all" to listOf(mapOf("field" to "score", "op" to "gte", "value" to 70), mapOf("field" to "email", "op" to "neq", "value" to "nobody@example.com"))),
listOf(mapOf("field" to "score", "order" to "desc")),
)
for (c in good) println("${c["data"].obj()["score"]} ${c["data"].obj()["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:
val company = api("POST", "/records", mapOf("object" to "company", "data" to mapOf("name" to "Example Ltd $run")))
val companyId = company["id"] as String
api(
"POST", "/links",
mapOf("relationship" to "works_at", "source_record_id" to adaId, "target_record_id" to companyId),
idempotencyKey = "link-$adaId-$companyId",
)
val related = api("GET", "/records/$adaId/related?relationship=works_at")["records"].list()
println("Ada works at ${related.joinToString(", ") { it["data"].obj()["name"] as String }}")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.
val bytes = "Meeting notes for Ada ($run)\n".toByteArray()
// 1. Announce the file, 2. send the raw bytes, 3. attach it to a record.
val file = api("POST", "/files", mapOf("filename" to "notes.txt", "content_type" to "text/plain", "size" to bytes.size))
val fileId = file["id"] as String
send("PUT", "/files/$fileId/content", body = bytes)
api("POST", "/attachments", mapOf("file_id" to fileId, "record_id" to adaId))
// Download with the key (server side) ...
val back = send("GET", "/files/$fileId/content").body()
// ... or make a signed link that expires, for an <img> or a download button in your page.
val link = api("POST", "/files/$fileId/links", mapOf("expires_in_seconds" to 600))
println("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.
try {
api("GET", "/records/rec_doesnotexist")
} catch (e: AgentDBError) { // a network error is an IOException, not an AgentDB answer
println(e.message) // 404 NOT_FOUND: ...
if (e.retryable) println("Safe to retry later")
}HttpClient throws IOException 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.
val key = UUID.randomUUID().toString() // one key per intention; reuse it only to retry the same request
val request = mapOf("object" to "contact", "data" to mapOf("name" to "Mary Somerville", "email" to "mary-$run@example.com"))
val first = api("POST", "/records", request, idempotencyKey = key)
val retry = api("POST", "/records", request, idempotencyKey = key) // e.g. after a timeout
println(if (first["id"] == retry["id"]) "Same record, written once" else "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/kotlin/Crm.kt. Set the three environment variables and run:
kotlinc Crm.kt -include-runtime -d crm.jar && java -jar crm.jarIt 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.