Skip to content

Latest commit

 

History

202 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

things cloud sdk

Things comes with a cloud based API, which can be used to synchronize data between devices. This is a golang SDK to interact with that API, opening the API so that you can enhance your Things experience on iOS and Mac.

Go

Getting Started

Installation

go get github.com/arthursoares/things-cloud-sdk

Quick Start

1. Set up your credentials:

export THINGS_USERNAME='your@email.com'
export THINGS_PASSWORD='yourpassword'

2. Create a simple Go program:

package main

import (
    "fmt"
    "os"
    "time"

    things "github.com/arthursoares/things-cloud-sdk"
)

func main() {
    client := things.New(
        things.APIEndpoint,
        os.Getenv("THINGS_USERNAME"),
        os.Getenv("THINGS_PASSWORD"),
    )

    // Verify credentials
    resp, err := client.Verify()
    if err != nil {
        panic(err)
    }
    fmt.Printf("✓ Connected: %s\n", resp.Email)

    // Get your account's history and sync it (required before writing —
    // the commit's ancestor-index comes from the synced head)
    history, err := client.OwnHistory()
    if err != nil {
        panic(err)
    }
    if err := history.Sync(); err != nil {
        panic(err)
    }

    // Create a task. NewUUID() produces the canonical Base58 identifier
    // format Things requires; Write rejects anything else.
    task := things.TaskActionItem{
        Item: things.Item{
            UUID:   things.NewUUID(),
            Kind:   things.ItemKindTask,
            Action: things.ItemActionCreated,
        },
        P: things.TaskActionItemPayload{
            Title:        things.String("My first task from the SDK!"),
            Status:       things.Status(things.TaskStatusPending),
            Schedule:     things.Schedule(things.TaskScheduleInbox),
            CreationDate: things.Time(time.Now()),
        },
    }

    if err := history.Write(task); err != nil {
        panic(err)
    }
    fmt.Println("✓ Created task")
}

Tip: for writes, prefer things-cli (below) — its payloads replicate real Things.app traffic field-for-field and are verified against HAR captures. The raw SDK write API is lower-level and sends sparse payloads.

3. Run it:

go run main.go

CLI Quick Start

Install and use the command-line tool:

# Install
go install github.com/arthursoares/things-cloud-sdk/cmd/things-cli@latest

# Create a task
things-cli create "Buy groceries" --when today

# List today's tasks
things-cli list --today

# Complete a task
things-cli complete <task-uuid>

Features

  • Verify Credentials — validate account access
  • Account Management — signup, confirmation, password change, deletion
  • History Management — list, create, delete, sync histories
  • Item Read/Write — full event-sourced CRUD for tasks, areas, tags, checklist items (supports batching multiple items in one request)
  • Task Types — tasks, projects, and headings (action groups within projects)
  • Structured Notes — full-text and delta patch support for task notes
  • Recurring Tasks — neverending, end on date, end after N times
  • Tombstone Deletion — explicit deletion records via Tombstone2 entities
  • Device Registration — register app instances for APNS push notifications
  • Alarm/Reminders — alarm time offset support on tasks
  • State Aggregation — in-memory state built from history items, with queries for projects, headings, subtasks, areas, tags, and checklist items
  • Persistent Sync Engine — SQLite-backed incremental sync with semantic change detection

CLI

things-cli is a command-line tool for interacting with Things Cloud directly.

Setup

export THINGS_USERNAME='your@email.com'
export THINGS_PASSWORD='yourpassword'
go build -o things-cli ./cmd/things-cli/

Commands

# Read
things-cli list [--today] [--inbox] [--anytime] [--someday] [--upcoming] [--search QUERY] [--area NAME] [--project NAME]
things-cli today
things-cli inbox
things-cli anytime
things-cli someday
things-cli upcoming
things-cli search <query>
things-cli show <uuid>
things-cli areas
things-cli projects
things-cli tags

# Optional read-state cache location
export THINGS_CLI_CACHE=/path/to/things-cli-state.json

# Create
things-cli create "Title" [--note ...] [--when today|anytime|someday|inbox] \
  [--deadline YYYY-MM-DD] [--scheduled YYYY-MM-DD] \
  [--project UUID] [--heading UUID] [--area UUID] \
  [--tags UUID,...] [--type task|project|heading]
things-cli create-area "Name"
things-cli create-tag "Name" [--shorthand KEY] [--parent UUID]

# Modify
things-cli edit <uuid> [--title ...] [--note ...] [--when ...] [--deadline ...] [--scheduled YYYY-MM-DD]
things-cli complete <uuid>
things-cli trash <uuid>
things-cli purge <uuid>
things-cli move-to-today <uuid>

# Batch (all operations in one HTTP request - much faster!)
# UUIDs must be canonical Base58 identifiers, as returned by create/list
echo '[{"cmd":"complete","uuid":"BXmAcvS6yK1eDhW31MuZrL"},{"cmd":"trash","uuid":"VJ1edXTP9q3PmFDUuy8EQh"}]' | things-cli batch

--scheduled uses the local calendar date: future dates appear in Upcoming, while today and past dates use the Today/Anytime schedule. If --when is also provided, its schedule takes precedence and --scheduled still supplies the date. For batch creates, pass the date as "extra":{"scheduled":"YYYY-MM-DD"}.

Examples

Write commands reject invalid relationship identifiers, schedule/type names, and calendar dates before sending a commit. Tag IDs must be canonical Base58 with no surrounding whitespace. Batch input is validated in full before its single commit; see CLI write validation for supported options and batch extra rules.

# Create a project with tasks
things-cli create "My Project" --type project --when anytime
# → {"status":"created","uuid":"BXmAcvS6yK1eDhW31MuZrL","title":"My Project"}

things-cli create "First Task" --project BXmAcvS6yK1eDhW31MuZrL --when today --note "Details here"

# Create an area and assign tasks
things-cli create-area "Work"
things-cli create "Review PR" --area <area-uuid> --when today --deadline 2026-02-15

# Batch operations (50 ops in ~2 sec instead of ~2-3 min)
echo '[
  {"cmd": "create", "title": "Task 1"},
  {"cmd": "create", "title": "Task 2"},
  {"cmd": "move-to-project", "uuid": "VJ1edXTP9q3PmFDUuy8EQh", "project": "BXmAcvS6yK1eDhW31MuZrL"},
  {"cmd": "complete", "uuid": "FQxaqvLBkbR5q2Q5oRoknc"}
]' | things-cli batch

Advanced SDK Usage

Working with Histories and Items

package main

import (
    "fmt"
    "os"

    things "github.com/arthursoares/things-cloud-sdk"
)

func main() {
    client := things.New(
        things.APIEndpoint,
        os.Getenv("THINGS_USERNAME"),
        os.Getenv("THINGS_PASSWORD"),
    )

    history, _ := client.OwnHistory()
    _ = history.Sync()

    // Create a project, then a task referencing it
    projectID := things.NewUUID()
    project := things.TaskActionItem{
        Item: things.Item{UUID: projectID, Kind: things.ItemKindTask, Action: things.ItemActionCreated},
        P: things.TaskActionItemPayload{
            Title:    things.String("My Project"),
            Type:     things.TaskTypePtr(things.TaskTypeProject),
            Schedule: things.Schedule(things.TaskScheduleAnytime),
        },
    }

    task := things.TaskActionItem{
        Item: things.Item{UUID: things.NewUUID(), Kind: things.ItemKindTask, Action: things.ItemActionCreated},
        P: things.TaskActionItemPayload{
            Title:         things.String("First task"),
            ParentTaskIDs: &[]string{projectID},
            Schedule:      things.Schedule(things.TaskScheduleAnytime),
        },
    }

    // One commit per Write; each item needs a unique canonical UUID —
    // Write() validates both and refuses anything that would corrupt
    // the sync history.
    if err := history.Write(project, task); err != nil {
        panic(err)
    }
    fmt.Println("✓ Created project with a task")
}

See the example/ directory for more complete examples including history sync, task creation, and state aggregation.

Persistent Sync Engine

The sync package provides a SQLite-backed sync engine that tracks "what changed since last sync" — perfect for building agents, automations, or dashboards that react to Things changes.

package main

import (
    "fmt"
    "os"
    things "github.com/arthursoares/things-cloud-sdk"
    "github.com/arthursoares/things-cloud-sdk/sync"
)

func main() {
    client := things.New(
        things.APIEndpoint,
        os.Getenv("THINGS_USERNAME"),
        os.Getenv("THINGS_PASSWORD"),
    )

    // Open persistent sync database
    syncer, _ := sync.Open("things.db", client)
    defer syncer.Close()

    // Fetch changes since last sync
    changes, _ := syncer.Sync()

    for _, c := range changes {
        switch v := c.(type) {
        case sync.TaskCreated:
            fmt.Printf("New task: %s\n", v.Task.Title)
        case sync.TaskCompleted:
            fmt.Printf("Completed: %s\n", v.Task.Title)
        case sync.TaskMovedToToday:
            fmt.Printf("Moved to Today: %s\n", v.Task.Title)
        }
    }

    // Query current state
    state := syncer.State()
    inbox, _ := state.TasksInInbox(sync.QueryOpts{})
    projects, _ := state.AllProjects(sync.QueryOpts{})
}

Task7 writes, reads, and recovery

The CLI uses Task7 for validated ordinary task, project, and heading creation and modification. History.Write also accepts explicit ItemKindTask7 envelopes within that scope. It checks existing update targets against raw history and rejects recurring or unknown targets before posting. This adds a history read for update requests. Direct recurrence configuration, note-delta writes, and Task7 deletion events remain unsupported; other entity formats, including Tombstone2 purge, are unchanged.

Older or sparse task histories that never explicitly establish rr, rp, and rt are also rejected by CLI updates, even when the task may be ordinary. This is a compatibility limit of the first Task7 rollout: the checker does not guess missing recurrence state and does not silently retry through Task6.

Existing SDK callers retain ItemKindTask == "Task6" and their previous write behavior. The readers accept both versions and older task kinds. Migrating outgoing writes does not rewrite stored tasks or history. See Task7 write policy for the exact boundary, SDK usage, and mixed-version live evidence.

The first read after this upgrade replays state built by an older task reader:

  • things-cli saves the old JSON cache as <cache-path>.before-replay-<random>.bak, then atomically replaces the cache after successful replay.
  • The SQLite sync engine keeps opening databases offline. On the next Sync(), it detects the old replay generation even if the cursor is already at the cloud head. It creates a consistent <database-path>.task7-backup-<random> backup, rebuilds derived state in memory, and installs it in one transaction.
  • Existing SQLite change-log rows, IDs, and timestamps are retained. Historical replay below the old cursor produces no duplicate log entries or notifications. Old Task7 UnknownChange rows remain audit evidence; newly fetched events after that cursor are logged normally.
  • Failed or incomplete replay leaves the existing database/cache available for retry. Backups are kept with owner-only permissions. Each SQLite attempt snapshots the current database; identical validated snapshots are deduplicated by full content so repeated failures do not accumulate identical backups. Staging requires memory proportional to the rebuilt state, and each attempt needs temporary free space for a full database snapshot.

Call Sync() before relying on database queries after upgrading: Open() does not perform network recovery. A future unsupported task kind returns an incomplete-sync error instead of silently advancing the cursor. Diagnostics do not include task payloads.

CLI cache writes use atomic replacement and optimistic change detection, not a cross-process lock. Concurrent readers can replace one another's complete cache snapshots; a later read catches up from the saved cursor. Give concurrent consumers distinct THINGS_CLI_CACHE paths if they must not share cache writes. SQLite recovery separately checks its saved metadata within the installation transaction.

Scheduled dates now use sr only; tir is a separate Today ordering reference date. Omitted task fields preserve prior values, while explicit nulls clear supported nullable dates, notes, and relationships. Modern repeater (rp) semantics remain incompletely verified; unknown wire fields remain available in raw Item.P and are not newly interpreted by the task model. The existing ReminderDate API field tagged rmd is a legacy misnomer for repeater migration date.

Semantic Change Types

The sync engine detects 40+ semantic change types:

Category Changes
Task Lifecycle TaskCreated, TaskCompleted, TaskUncompleted, TaskTrashed, TaskDeleted
Task Movement TaskMovedToInbox, TaskMovedToToday, TaskMovedToAnytime, TaskMovedToSomeday, TaskMovedToUpcoming
Task Organization TaskMovedToProject, TaskMovedToArea, TaskMovedUnderHeading, TaskTagsChanged
Task Details TaskTitleChanged, TaskNoteChanged, TaskDeadlineSet, TaskDeadlineRemoved
Projects ProjectCreated, ProjectCompleted, ProjectTrashed, ProjectDeleted
Areas & Tags AreaCreated, AreaDeleted, TagCreated, TagDeleted
Checklists ChecklistItemCreated, ChecklistItemCompleted, ChecklistItemDeleted

State Queries

state := syncer.State()

// Query by location
inbox, _ := state.TasksInInbox(sync.QueryOpts{})
today, _ := state.TasksInToday(sync.QueryOpts{})
anytime, _ := state.TasksInAnytime(sync.QueryOpts{})
someday, _ := state.TasksInSomeday(sync.QueryOpts{})
upcoming, _ := state.TasksInUpcoming(sync.QueryOpts{})

// Query by container
tasks, _ := state.TasksInProject(projectUUID, sync.QueryOpts{})
tasks, _ := state.TasksInArea(areaUUID, sync.QueryOpts{})
tasks, _ := state.TasksUnderHeading(headingUUID, sync.QueryOpts{})
headings, _ := state.HeadingsInProject(projectUUID, sync.QueryOpts{})

// Query by tag or text
tasks, _ := state.TasksWithTag(tagUUID, sync.QueryOpts{})
tasks, _ := state.SearchTasks("invoice", sync.QueryOpts{})

// List all
projects, _ := state.AllProjects(sync.QueryOpts{})
headings, _ := state.AllHeadings(sync.QueryOpts{})
areas, _ := state.AllAreas(sync.QueryOpts{})
tags, _ := state.AllTags(sync.QueryOpts{})

Change Log Queries

// Changes in last hour
changes, _ := syncer.ChangesSince(time.Now().Add(-1 * time.Hour))

// Changes for a specific task
changes, _ := syncer.ChangesForEntity(taskUUID)

// Changes since server index
changes, _ := syncer.ChangesSinceIndex(150)

Wire Format Notes

Key findings from reverse engineering the Things Cloud sync protocol:

  • UUIDs must be Base58-encoded (Bitcoin alphabet: 123456789ABCDEFGH...). Standard UUID strings or other encodings will crash Things.app during sync.
  • md (modification date) must be null on creates. Set timestamps only on updates.
  • Schedule field (st): 0 = Inbox, 1 = Anytime/Today (with sr/tir dates = Today), 2 = Someday/Upcoming (with dates = Upcoming).
  • Status field (ss): 0 = Pending, 2 = Canceled, 3 = Completed. Don't confuse with st (schedule)!
  • Headings (tp=2) must have st=1 (anytime). st=0 (inbox) crashes Things.app.
  • Tasks in projects, headings, or areas should default to st=1 (anytime) — they've been triaged out of inbox.
  • Kind strings: Task7 for verified CLI task writes; Task6 remains supported for existing SDK callers; Tag4, ChecklistItem3, Area3, Tombstone2 for other entities.

Since v0.3.0 the SDK enforces the identifier rules instead of trusting callers: things.NewUUID() generates canonical Base58 identifiers (one leading 1 per leading zero byte — a subtlety whose absence used to corrupt ~1 in 256 creates), things.ValidateUUID() checks any identifier, and History.Write() refuses items with invalid or duplicate UUIDs before anything reaches the server.

See docs/client-side-bugs.md for the full investigation and crash analysis.

Architecture

The SDK models all changes as immutable Items (events). A History is a sync stream identified by a UUID. The client pushes/pulls Items through Histories, inspired by operational transformations and Git's internals.

TODO

  • Repeat after completion
  • Persistent state storage (see sync package)

Note

As there is no official API documentation available all requests need to be reverse engineered, which takes some time. Feel free to contribute and improve & extend this implementation.

About

golang client for the culturedcode things cloud

Resources

Stars

57 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages