SDK
Go SDK
A dependency-free client for the Atlas REST API, generated from the OpenAPI spec. Works in any Go 1.22+ project.
Overview
The Atlas Go SDK wraps the same REST surface documented in the API guide. NewClient returns a *Client with a method per operation; methods return decoded JSON as any and a typed error on non-2xx responses. It is generated from docs/atlas-openapi.json, so method names track the API exactly.
Method naming
operationId. Listing tasks (operation atlas_get_tasks) is client.AtlasGetTasks(...). The methods overview maps the common resources.Install
Fetch the module with go get. It pulls in nothing beyond the standard library.
go get github.com/atlas-app/atlas-go-sdkAuthentication
Every request carries a Personal Access Token as a bearer credential. Tokens start with atlas_pat_ and are scope-bounded and revocable.
package main
import (
"log"
"os"
atlas "github.com/atlas-app/atlas-go-sdk"
)
func main() {
// Read the token from the environment - never hard-code it in source.
client, err := atlas.NewClient(os.Getenv("ATLAS_TOKEN"))
if err != nil {
log.Fatal(err)
}
_ = client
}Scopes are enforced server-side
403 (insufficient_scope) and returns a *ForbiddenError - the SDK never widens a token. Mint the narrowest scope set that works and widen only when a call is refused.Quickstart
One authenticated request, end to end, continuing from the client above. Set ATLAS_TOKEN in your environment first.
res, err := client.AtlasGetTasks(map[string]string{"limit": "5"})
if err != nil {
log.Fatal(err)
}
// List endpoints answer an { "items": [...], "nextCursor": ... } envelope.
page := res.(map[string]any)
for _, item := range page["items"].([]any) {
task := item.(map[string]any)
fmt.Println(task["id"], task["title"])
}Core examples
The everyday task and project operations. Reads answer an { items, nextCursor } envelope decoded as map[string]any.
List tasks with filters
// Filters map straight to the REST query string; query values are strings.
res, err := client.AtlasGetTasks(map[string]string{
"projectId": "prj_123",
"status": "IN_PROGRESS",
"assigneeId": "usr_42",
"limit": "50",
})
if err != nil {
log.Fatal(err)
}
tasks := res.(map[string]any)["items"].([]any)Get a task
// Single-resource routes carry the id in the path. The typed method does not
// interpolate it, so compose these with the standard library and your token.
func getTask(ctx context.Context, token, id string) (map[string]any, error) {
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, "https://api.atlas.app/v1/tasks/"+id, nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var task map[string]any
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
return nil, err
}
return task, nil
}Single-resource routes
AtlasGetTasksRecord targets /tasks/{id} but does not interpolate the id, and the transport takes no per-call context. Compose these with net/http as shown; the same applies to update and delete.Create a task
created, err := client.AtlasPostTasks(map[string]any{
"projectId": "prj_123",
"title": "Review Q3 forecast",
"priority": "HIGH",
"dueOn": "2026-05-01T17:00:00Z",
}, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("created", created.(map[string]any)["id"])Update a task
// PATCH /tasks/{id} - a single-resource write, composed like the GET above.
payload, _ := json.Marshal(map[string]any{"status": "DONE"})
req, _ := http.NewRequest(
http.MethodPatch, "https://api.atlas.app/v1/tasks/"+taskID, bytes.NewReader(payload),
)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)Cursor-based pagination
func iterTasks(client *atlas.Client, filters map[string]string) ([]any, error) {
var all []any
cursor := ""
for {
q := map[string]string{"limit": "100"}
for k, v := range filters {
q[k] = v
}
if cursor != "" {
q["cursor"] = cursor
}
res, err := client.AtlasGetTasks(q)
if err != nil {
return nil, err
}
page := res.(map[string]any)
if items, ok := page["items"].([]any); ok {
all = append(all, items...)
}
next, _ := page["nextCursor"].(string)
if next == "" {
return all, nil
}
cursor = next
}
}List projects
res, err := client.AtlasGetProjects(map[string]string{"limit": "50"})
if err != nil {
log.Fatal(err)
}
for _, item := range res.(map[string]any)["items"].([]any) {
project := item.(map[string]any)
fmt.Println(project["id"], project["name"])
}Error handling
Non-2xx responses return a typed error. Match it with errors.As; every typed error embeds *ApiError, so Status, Message, and Body are always available.
_, err := client.AtlasPostTasks(map[string]any{"title": ""}, nil)
var forbidden *atlas.ForbiddenError
var unauthorized *atlas.UnauthorizedError
var apiErr *atlas.ApiError
switch {
case errors.As(err, &forbidden):
// 403: the token is missing a scope. Body is the parsed problem+json.
if body, ok := forbidden.Body.(map[string]any); ok {
fmt.Println("need scopes:", body["requiredScopes"])
}
case errors.As(err, &unauthorized):
// 401: token invalid or revoked - re-authenticate.
log.Fatal("unauthorized")
case errors.As(err, &apiErr):
// 409 / 422 / 429: no dedicated type, so branch on Status.
fmt.Println(apiErr.Status, apiErr.Message)
}| HTTP status | Error class | Meaning |
|---|---|---|
| 400 | *atlas.BadRequestError | Malformed request or query. |
| 401 | *atlas.UnauthorizedError | Token missing, invalid, expired, or revoked. |
| 403 | *atlas.ForbiddenError | Token is missing a required scope. |
| 404 | *atlas.NotFoundError | Resource does not exist or is out of tenant. |
| 409, 422, 429 | ApiError (base) | No dedicated class - branch on the status code and read the body. |
| 5xx | *atlas.ServerError | Transient server fault. Safe to retry. |
Only the statuses above get a dedicated type. Conflicts (409), validation failures (422), and rate limits (429) return the base *ApiError, so branch on Status and read Body. Every error wraps the API's RFC 9457 problem+json envelope:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
x-request-id: f47ac10b-58cc-4372-a567-0e02b2c3d479
{
"type": "https://atlas.dev/errors/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This Personal Access Token is missing one of the required scopes: tasks:write.",
"requiredScopes": ["tasks:write"],
"grantedScopes": ["tasks:read"],
"requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Body decodes to that JSON object: type, title, status, detail, and a requestId to quote in support tickets. A 422 adds an errors array; a 429 adds a rateLimit object.
Retries and rate limits
Retry 429 and 5xx; never retry 4xx you caused. The 429 body carries the retry delay, so honour it before falling back to backoff.
var retryable = map[int]bool{429: true, 500: true, 502: true, 503: true, 504: true}
// retryDelay reports how long to wait, or ok=false to stop retrying.
func retryDelay(err error, attempt int) (time.Duration, bool) {
var server *atlas.ServerError
var apiErr *atlas.ApiError
switch {
case errors.As(err, &server):
return backoff(attempt), true // 5xx: transient
case errors.As(err, &apiErr) && apiErr.Status == 429:
// The 429 body carries the authoritative retry delay, in seconds.
if body, ok := apiErr.Body.(map[string]any); ok {
if rl, ok := body["rateLimit"].(map[string]any); ok {
if secs, ok := rl["retryAfterSec"].(float64); ok {
return time.Duration(secs) * time.Second, true
}
}
}
return backoff(attempt), true
default:
return 0, false
}
}
func backoff(attempt int) time.Duration {
d := time.Duration(1<<uint(attempt-1)) * time.Second
if d > 30*time.Second {
d = 30 * time.Second
}
return d + time.Duration(rand.Intn(1000))*time.Millisecond // jitter
}
func withRetries[T any](call func() (T, error), maxAttempts int) (T, error) {
for attempt := 1; ; attempt++ {
res, err := call()
if err == nil {
return res, nil
}
delay, ok := retryDelay(err, attempt)
if !ok || attempt == maxAttempts {
var zero T
return zero, err
}
time.Sleep(delay)
}
}Back off with jitter
retryAfterSec is present, use exponential backoff with a random jitter so a fleet of clients does not retry in lockstep. Cap total attempts - a persistent 429 means you need a higher tier, not more retries.Idempotency
Every write accepts an Idempotency-Key header. Replaying the same key within 24h returns the original response verbatim - safe retries on flaky networks.
// The generated client sends a fixed header set and takes no per-call context,
// so attach an Idempotency-Key by composing the write directly. Reuse one key
// across every retry so the API replays (24h) instead of duplicating.
func createTask(ctx context.Context, token, key string, body map[string]any) (map[string]any, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, "https://api.atlas.app/v1/tasks", bytes.NewReader(payload),
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var created map[string]any
return created, json.NewDecoder(resp.Body).Decode(&created)
}Reuse the key on retry
Configuration
NewClient takes functional options. WithBaseURL retargets staging or self-hosted; WithHTTPClient supplies your own timeout, transport, and headers.
// headerTransport adds a static header to every request the client makes.
type headerTransport struct {
base http.RoundTripper
headers map[string]string
}
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for k, v := range t.headers {
req.Header.Set(k, v)
}
return t.base.RoundTrip(req)
}
func newClient(token string) (*atlas.Client, error) {
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &headerTransport{
base: http.DefaultTransport,
headers: map[string]string{"X-Request-Source": "atlas-cli"},
},
}
return atlas.NewClient(
token,
atlas.WithBaseURL("https://api.atlas.app/v1"), // staging or self-hosted
atlas.WithHTTPClient(httpClient),
)
}A custom http.RoundTripper is the place to add a static header (a request source, a trace id) or wire in your own retry and logging middleware for every call.
Methods overview
The common task and project operations. The full set - one method per operation - lives in the API reference.
| Resource | REST | SDK method |
|---|---|---|
| List tasks | GET /tasks | AtlasGetTasks(query) |
| Create task | POST /tasks | AtlasPostTasks(body, query) |
| Get task | GET /tasks/{id} | AtlasGetTasksRecord * |
| Update task | PATCH /tasks/{id} | AtlasPatchTasksRecord * |
| Delete task | DELETE /tasks/{id} | AtlasDeleteTasksRecord * |
| List projects | GET /projects | AtlasGetProjects(query) |
| Create project | POST /projects | AtlasPostProjects(body, query) |
* Single-resource methods exist but send the literal {id} path; compose them with net/http (see Get a task).
Next steps
The SDK is a thin, faithful layer over the REST API. When you need the exact shape of a request or response, go to the source.
- REST API guideAuth, pagination, idempotency, and rate limits at the protocol level - the contract every SDK speaks./docs/api
- API referenceEvery operation, request body, and response shape - the source of truth for method arguments./docs/api/reference
- MCP for AI agentsHand the same operations to an AI agent over the Model Context Protocol./docs/mcp