# Go SDK

> The Go client for rt-control. Dial the local socket or mutual TLS, acquire and renew authority, jog, upload and start programs, follow events and telemetry, and handle typed refusals.

URL: https://advancedmetalresearch.com/docs/apis/go-sdk
Section: RosieOS docs / APIs
Last updated: 2026-10-10

Package `rosieos/rt-core/sdk/control` is the Go client for [rt-control](https://advancedmetalresearch.com/docs/apis/rt-control-http). It is what the offline programming server and the v4 pendant tooling use. It keeps separate connections for lifecycle, urgent (Stop, Release), bulk upload and renewal traffic, so a large upload can never delay a Stop or a renewal. It fills in the fence and a `request_id` on every command, and it returns refusals as a typed `*control.Rejected`.

The package builds on Linux only (`//go:build linux`).

> [!WARNING] The example below enables, arms and jogs the robot. Run it against the simulated core (`rosie-rt-core-sim`) first. On real hardware, keep the hardware E-stop within reach: it is the only emergency stop, and RosieOS has no software E-stop. See the [safety model](https://advancedmetalresearch.com/docs/get-started/safety-model).

## Example: acquire, jog, stop, release

This program is the simulation example from the rt-core README. It acquires, renews in the background, enables and arms all axes, waits for every drive to reach Operation Enabled, jogs axis 0 at 0.01 rad/s for one second, and then stops and releases. Start the simulated core and rt-control first (see [Run everything in simulation](https://advancedmetalresearch.com/docs/guides/run-in-simulation)), then set `TMPDIR` to the directory holding `control.sock` and `SHA` to the compiled configuration digest.

jog.go:

```go
package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"rosieos/rt-core/ipcclient"
	"rosieos/rt-core/sdk/control"
)

func must[T any](v T, err error) T { if err != nil { panic(err) }; return v }
func check(err error) { if err != nil { panic(err) } }

func main() {
	// Simulation inputs: nine axes, 0.01 rad/s, 1 s of input at a 10 ms cadence,
	// 100 ms input lifetime, 10 s overall budget.
	const mask, velocity, duration, cadence, age, budget = 511, 0.01, time.Second, 10 * time.Millisecond, 100 * time.Millisecond, 10 * time.Second
	ctx, cancel := context.WithTimeout(context.Background(), budget)
	defer cancel()

	c := must(control.Dial(control.UnixPath(os.Getenv("TMPDIR") + "/control.sock")))
	defer c.Close()
	d := must(c.Describe(ctx))
	if d.Backend != "simulation" || d.ConfigurationSHA256 != os.Getenv("SHA") {
		panic("simulation identity mismatch")
	}
	g := must(c.Acquire(ctx, "readme", control.Binding{PairID: "readme", Revision: 1, ConfigurationSHA256: d.ConfigurationSHA256}))
	released := false
	defer func() {
		if !released {
			c.Stop(context.Background())
			c.Release(context.Background())
		}
	}()
	fmt.Printf("acquire generation=%d\n", g.Generation)
	renewals := must(c.StartRenewal(ctx, 0))

	fmt.Printf("enable sequence=%d\n", must(c.Enable(ctx, mask)).Sequence)
	fmt.Printf("arm sequence=%d\n", must(c.Arm(ctx)).Sequence)
	tick := time.NewTicker(cadence)
	defer tick.Stop()
	for !ipcclient.AllOperationEnabled(must(c.Status(ctx)).Core) {
		select {
		case <-tick.C:
		case <-ctx.Done():
			panic(ctx.Err())
		}
	}

	before := must(c.Status(ctx)).Core.Axes[0].PositionCounts
	jog := must(c.PrepareJogSession(ctx, mask))
	velocities := make([]float64, len(d.Axes))
	velocities[0] = velocity
	for end := time.Now().Add(duration); time.Now().Before(end); {
		origin := ipcclient.HostMonotonicNS()
		check(jog.UpdateAt(velocities, origin, origin+uint64(age)))
		select {
		case r, ok := <-renewals:
			if !ok {
				panic("renewal ended")
			}
			check(r.Err)
		case <-tick.C:
		case <-ctx.Done():
			panic(ctx.Err())
		}
	}
	check(jog.End(ctx))

	delta := must(c.Status(ctx)).Core.Axes[0].PositionCounts - before
	fmt.Printf("jog requested_ns=%d delta_counts=%d\n", duration, delta)
	fmt.Printf("stop generation=%d\n", must(c.Stop(ctx)).Generation)
	fmt.Printf("release session_empty=%t\n", must(c.Release(ctx)).Session == "")
	released = true
}
```

Output recorded in the rt-core README:

```text
acquire generation=1
enable sequence=1
arm sequence=2
jog requested_ns=1000000000 delta_counts=207
stop generation=2
release session_empty=true
```

`sdk/control/example_motion_server_test.go` is a longer runnable example: it Homes, uploads an `.rdt` program, starts it and observes completion against the simulator. `make test-go` runs it.

## Add the module

The module path is `rosieos/rt-core`, which is not a fetchable URL. Point a `replace` directive at your checkout of the repository, as the offline programming server does:

go.mod:

```text
require rosieos/rt-core v0.0.0

replace rosieos/rt-core => ../RosieOS/rt-core
```

The module declares `go 1.24` with toolchain `go1.26.2`.

## Connect

| Function | Use |
|---|---|
| `control.Dial(path UnixPath) (*Client, error)` | The local socket, for example `control.Dial("/run/rosie-rt-core/control.sock")`. Local jog sessions dial `jog.sock` in the same directory. |
| `control.DialTLS(addr string, config *tls.Config) (*Client, error)` | The remote listener. `config` must have `RootCAs` and a client certificate, and must not set `InsecureSkipVerify`. TLS 1.3 is forced. See [Remote access](https://advancedmetalresearch.com/docs/apis/remote-access-mtls#connect-a-client). |
| `(*Client).Close() error` | Closes connections and stops renewal. It does **not** release authority: call `Release` first. |

Neither constructor connects: the first call does, bounded by its context.

## Authority

| Method | Returns | Notes |
|---|---|---|
| `Acquire(ctx, controller string, binding Binding)` | `Grant, error` | Measures Describe if needed and requests a lease sized to the round trip. |
| `AcquireLease(ctx, controller, binding, requested time.Duration)` | `Grant, error` | As Acquire, with a minimum requested lease. It never goes below the measured requirement. |
| `Renew(ctx)` | `Grant, error` | One renewal. |
| `StartRenewal(ctx, interval time.Duration)` | `<-chan Renewal, error` | Starts one background renewer. `interval` 0 means a third of the lease, and larger values are refused. It refuses to start (`control_session_stale`) when the lease cannot cover three round trips plus the interval. The channel holds only the latest `Renewal{Grant, Err, StartedHostMonotonicNS}`; any error ends renewal. |
| `StopRenewal()` | — | Stops and joins the renewer. It does not release. |
| `Stop(ctx)` | `Grant, error` | Uses the urgent connection. The reply carries the new generation. |
| `Release(ctx)` | `Grant, error` | Stops renewal, then releases. Never replayed automatically. |
| `Grant()`, `Fence()` | `Grant`, `Fence` | The client's current authority. |
| `SetFence(f Fence)` | — | Adopts a fence obtained elsewhere, before concurrent use. |
| `Timing()` | `LeaseTiming` | Measured round trip, effective lease, renewal interval and call budget. |
| `PrepareAcquisition(ctx)` | `AcquisitionSnapshot, error` | Warms remote connections and reads Status and events before `Acquire`. Grants nothing. |

`TimingForLease(lease, roundTrip)` and `RequestedLease(roundTrip, ceiling)` are the helpers behind the lease sizing. See [Control authority](https://advancedmetalresearch.com/docs/concepts/control-authority#lease-timing).

## Commands

Each method sends one [rt-control operation](https://advancedmetalresearch.com/docs/apis/rt-control-http) with the current fence and a fresh `request_id`.

| Method | Operation | Result |
|---|---|---|
| `Enable(ctx, mask uint32)` | `enable` | `Response` (`.Sequence`) |
| `Arm(ctx)` | `arm` | `Response` |
| `Home(ctx, mask uint32)` | `home` | `Response` |
| `RestoreAnchor(ctx, mask uint32)` | `restore_anchor` | `RecoveryStatus` |
| `ResetFault(ctx, mask uint32)` | `reset_fault` | `Response`; the client also forgets the retired fence |
| `IOArm(ctx)`, `IODisarm(ctx)` | `io_arm`, `io_disarm` | `Response` |
| `PrepareTrajectory(ctx, mask uint32, points []Point)` | `prepare_trajectory` | `Response` (`.Handle`) |
| `StartTrajectory(ctx, handle uint64)` | `start_trajectory` | `Response` |
| `DiscardTrajectory(ctx, handle uint64)` | `discard_trajectory` | `Response` |
| `PrepareProgram(ctx, blob []byte)` | `prepare_program` | `Program` |
| `StartProgram(ctx, identity Identity)` | `start_program` | `Response` |
| `BeginJog(ctx, mask, clock string, originNS, deadlineNS uint64)` | `begin_jog` | `Response` (`.Handle` is the jog generation) |
| `EndJog(ctx, generation uint64)` | `end_jog` | `Response` |
| `Jog(ctx, mask, velocity []float64, timeoutMS uint32)` | `jog` (test only) | `Response` |

There are no convenience methods for `halt`, `recovery_status` or `mark_telemetry`. Send them with `Command`:

```go
resp, err := c.Command(ctx, &control.Request{Operation: "halt"})
```

`Command(ctx, *Request)` fills in `schema`, the fence and a random `request_id` the first time, and writes them back into the request. To retry a lost reply, call `Command` again with **the same** `*Request` and a fresh context; the adapter then replays the first outcome instead of running the command twice. Never share one request between goroutines or change it between attempts. `NewRequestID()` returns a random 32-character hex ID if you want to set your own.

`PrepareProgram` sends the `.rdt` bytes with the session and generation headers. Uploads are not deduplicated and never retried: if one fails uncertainly, inspect Describe and Status, or Stop, before uploading again.

## Jog

| API | Use |
|---|---|
| `PrepareJogSession(ctx, mask)` | Begin a local jog session without an input sample; the first input's allowance is the cell's input-age ceiling. |
| `NewJogSession(ctx, mask, deadlineNS)`, `NewJogSessionAt(ctx, mask, originNS, deadlineNS)` | Begin with a first input captured now or at `originNS`. |
| `(*JogSession).UpdateAt(velocities, originNS, deadlineNS)`, `Update(velocities, deadlineNS)` | Send one datagram. Capture times must increase. |
| `(*JogSession).End(ctx)` | Close the socket, then end the jog generation. |
| `(*JogSession).Generation()` | The jog generation. |
| `NewRemoteJogSession(ctx, mask, durationNS, sourceNow)` | The WebSocket lane on a `DialTLS` client; see [remote jog](https://advancedmetalresearch.com/docs/apis/remote-access-mtls#remote-jog-over-websocket). |
| `BuildJogFrame(...)` | Encode one 224-byte `local_jog_update` frame yourself. |

Times are host `CLOCK_MONOTONIC` nanoseconds; `ipcclient.HostMonotonicNS()` reads that clock. A deadline may be at most the cell's `max_jog_input_age_ns` after its origin. `Update` never blocks: when the socket is congested it returns `ErrJogWouldBlock`, and you should drop that sample and send a fresh one. Local datagrams get no reply, so read refusals from `JogIngress`.

## Observe

| Method | Returns |
|---|---|
| `Describe(ctx)` | `Description`. Also records the round trip and the cell's lease and jog ceilings. |
| `Status(ctx)` | `Status` (the schema's `ProcessStatus`) |
| `JogClock(ctx)`, `JogStatus(ctx)`, `JogIngress(ctx)` | `JogClock`, `JogObservation`, `JogIngressObservation` |
| `Events(after)`, `EventsContext(ctx, after)` | `EventBatch`, validated for order and loss |
| `StreamEvents(ctx, after, func(Event) error)` | Runs until the context ends or the handler returns an error |
| `TelemetryBatches(ctx, after)` | One `TelemetryBatch` (`Header`, `Records`) |
| `TelemetryStream(ctx, after, func(TelemetryBatch) error)` | Follows the stream; returns on an incarnation change |
| `TelemetryPeek(ctx, after)` | The header and first record only |
| `Resource(ctx, sha)` | `io.ReadCloser`, returned only after the size and ETag match the digest |

See [Events and telemetry](https://advancedmetalresearch.com/docs/apis/rt-control-events-telemetry) for cursor handling.

## Errors

```go
g, err := c.Acquire(ctx, "my-app", binding)
var rejected *control.Rejected
switch {
case errors.As(err, &rejected):
	// A 409 refusal. rejected.Reason is the reason code; NativeResult,
	// NativeJogResult and ResponseData carry any structured evidence.
	log.Printf("refused: %s", rejected.Reason)
case errors.Is(err, control.ErrConnectionUnsent):
	// The request never left this process. Safe to retry.
case errors.Is(err, control.ErrTransport):
	// The outcome is unknown. Stop, then reconcile Status before acquiring again.
case err != nil:
	log.Fatal(err)
}
_ = g
```

| Error | Meaning |
|---|---|
| `*Rejected` | HTTP 409. `Reason` is a [reason code](https://advancedmetalresearch.com/docs/reference/error-codes). `NativeResult` and `NativeJogResult` are the native receipts; `ResponseData` keeps `data` (for example `limit_violation`) without losing integer precision. |
| `*LeaseTimingRejected` | The lease cannot cover the measured round trip. Wraps a `*Rejected` with reason `control_session_stale` and reports `RoundTrip` and `Ceiling`. |
| `ErrTransport` | The connection failed. The outcome may be unknown. |
| `ErrConnectionUnsent` | The request was not sent. |
| `ErrRequestTimeout` | The request budget expired (joined with `context.DeadlineExceeded`). |
| `ErrProtocol` | A malformed reply, or an unexpected status such as 413 or 404. |
| `ErrClosed` | The client is closed. |
| `ErrJogWouldBlock` | A jog datagram was not sent. Replace it with fresh input. |

The client retries a request at most once, after a closed or stale connection, reusing the same `request_id`. It never retries a `release` or an `.rdt` upload whose outcome is uncertain.

## Types

The package re-exports the contract types, so you rarely need the adapter package directly: `Request`, `Fence`, `Binding`, `Grant`, `Identity`, `Program`, `Execution`, `HandleRecord`, `ProcessMarker`, `Description`, `RecoveryStatus`, `CapabilityInfo`, `Point`, `JogClock`, `JogObservation`, `JogIngressObservation`, `JogIngressRefusal`, `Response` (the raw envelope, with `Data` as `json.RawMessage`), `Status`, `Event`, `EventBatch`, `EventsDropped`, `TelemetryBatch`, `ResourceInfo`, `RobotDescription`, `DriveDescription` and `DriveIdentity`. Their fields are in the [types reference](https://advancedmetalresearch.com/docs/apis/rt-control-http#types). The capability states are exported as `CapabilityStateImplemented`, `CapabilityStateInterim`, `CapabilityStateTestOnly` and `CapabilityStateUnimplemented`. The reason codes are constants in the generated adapter package `rosieos/rt-core/adapters/rosie/control`, for example `ReasonControlSessionStale`.

## Related pages

- [rt-control HTTP API](https://advancedmetalresearch.com/docs/apis/rt-control-http)
- [Control authority](https://advancedmetalresearch.com/docs/concepts/control-authority)
- [C++ client](https://advancedmetalresearch.com/docs/apis/cpp-client)

## Sources

Written from these files in the RosieOS repository (https://github.com/advanced-metal-research/RosieOS):

- `rt-core/go.mod:1-5`
- `rt-core/sdk/control/client.go:27-112,182-280,329-440,444-500`
- `rt-core/sdk/control/operations.go:14-157`
- `rt-core/sdk/control/renewal.go:13-110`
- `rt-core/sdk/control/timing.go:14-90`
- `rt-core/sdk/control/jog.go:18-190`
- `rt-core/sdk/control/jog_remote.go:30-114`
- `rt-core/sdk/control/events.go:19-80`
- `rt-core/sdk/control/telemetry.go:17-121`
- `rt-core/sdk/control/resources.go:17-40`
- `rt-core/sdk/control/types.go:13-48`
- `rt-core/sdk/control/reopen.go:22`
- `rt-core/sdk/control/request_deadline.go:14`
- `rt-core/sdk/control/warm.go:253-280`
- `rt-core/sdk/control/example_motion_server_test.go:1-60`
- `rt-core/ipcclient/client_linux.go:1166`
- `rt-core/ipcclient/application_grant_linux.go:69`
- `offline-programming/v1/go.mod:10,21 (replace directive); example and output: rt-core/README.md:198-276 (narrative, recorded run)`
