Skip to main content
  1. Posts/

Better Go clients for ATProto

·3383 words·16 mins
Agent IO
Author
Agent IO
Table of Contents
Easily call XRPC APIs from your Go code and the command line.

Introducing Slink#

Early this year I built a tool for calling XRPC APIs. “XRPC” is what they call APIs described by Lexicon, the JSON-based API description mechanism that powers Bluesky and the AT Protocol. Lexicon is in the same genre as Protocol Buffers and OpenAPI, but it’s simpler and more constrained. That’s nice! It makes it easier to write Lexicon-based clients and code generators. You can read more in the XRPC spec.

To a certain extent, my slink tool wrote itself. It is a Go CLI, and its subcommands include code generators that produce Go client libraries and CLIs for arbitrary Lexicon API descriptions. These generators wrote the parts of slink that call XRPC APIs. I didn’t start with such a broad ambition – at first I was just wanting to replace the more-or-less official Go client libraries for Bluesky. These are in the bluesky-social/indigo repository and are created with lexgen. But once I had done that, I started writing a CLI to exercise my generated clients, and quickly realized that this, too, could be automatically generated.

The result is on GitHub.

Owning the whole project gave me the opportunity to add one more feature that I’ve always wanted in OpenAPI and Protobuf-based code generators: my generator optionally takes a simple JSON file called a “manifest” that lists just the APIs that I want to call. Then it generates exactly and only the code that I need. No generated code bloat! The result is the lightest-weight Go calling experience for XRPC short of handwriting your clients yourself.

Here’s the manifest for a Go program that sends Bluesky chat messages:

{
  "ids": [
	"com.atproto.server.createSession",
	"chat.bsky.convo.getConvoForMembers",
	"chat.bsky.convo.sendMessage"
  ]
}

What’s wrong with lexgen?
#

If Go client libraries already exist, why do this?

If you just want a tl;dr it’s this: lexgen was created by and for infrastructure developers who are working in the bluesky-social/indigo monorepo. It’s great for them, but if you’re just writing a light HTTP-based ATProto client, it’s got complexity that can kill your project before it gets started.

lexgen is not really documented
#

If you want to learn lexgen, you have to start with the source, and then go up to the indigo Makefile to see how lexgen is used. Why does this matter? If you only need to call the Bluesky APIs, it doesn’t, but if you’re using new lexicons that don’t have published clients, you need something to generate your structs and API-calling code.

lexgen configuration is confusing and unnecessary
#

Down in the lexgen sources, there’s a JSON file that lexgen uses to manage its mapping from Lexicons to Go packages. It’s here and is small enough to quote:

[
  {
    "package": "bsky",
    "prefix": "app.bsky",
    "outdir": "api/bsky",
    "import": "github.com/bluesky-social/indigo/api/bsky"
  },
  {
    "package": "atproto",
    "prefix": "com.atproto",
    "outdir": "api/atproto",
    "import": "github.com/bluesky-social/indigo/api/atproto"
  },
  {
    "package": "chat",
    "prefix": "chat.bsky",
    "outdir": "api/chat",
    "import": "github.com/bluesky-social/indigo/api/chat"
  },
  {
    "package": "ozone",
    "prefix": "tools.ozone",
    "outdir": "api/ozone",
    "import": "github.com/bluesky-social/indigo/api/ozone"
  }
]

What’s going on here? This is mapping well-structured hierarchical Lexicon name fragments (the prefix fields) to Go packages (the package fields) and specifying storage and import locations for generated packages. This configuration is only necessary because lexgen has a complicated mapping of Lexicons to packages. The Go package names don’t match the Lexicon prefixes and because generated code is put into multiple packages, the generator has to know what import paths to use to resolve cross-package dependencies. So you might ask “why are there multiple packages?” That’s one of the tools that we use to manage large amounts of code, and we have so much code because lexgen is generating code for everything so that it can be statically published in a repo. I think there’s a better way, demonstrated below with slink.

lexgen is messily entangled with CBOR
#

When I first started using lexgen, I tripped on a pretty terrible circular dependency.

The code that lexgen generated included tags that configure CBOR code generation. The CBOR code generator depended on this, so it would only work if that code could be compiled. But that code included references to CBOR generated code, so it wouldn’t compile until the CBOR generator had run! What (or why) the ever-loving F? This has been raised in a GitHub issue that has been open for over a year. What’s wrong here? CBOR. The maddening thing for XRPC application developers is that most of them never use CBOR or even need to know anything about it. This is a clear instance of the cart (infrastructure developers) pulling the horse (app developers) in the indigo repo, but it’s not the only one.

lexgen’s client libraries are in the indigo repo
#

The more-or-less official (my words) Go XRPC client libraries are in the indigo repo. This is a big repo. It’s effectively a monorepo for Bluesky’s Go infrastructure work and as a result, it has an enormous list of dependencies. If you’re writing a little TODO-list ATProto app, that is the last thing that you want to see.

The generated code itself doesn’t have all these dependencies. Here’s an example that sends a chat message:

// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.

// Lexicon schema: chat.bsky.convo.sendMessage

package chat

import (
	"context"

	lexutil "github.com/bluesky-social/indigo/lex/util"
)

// ConvoSendMessage_Input is the input argument to a chat.bsky.convo.sendMessage call.
type ConvoSendMessage_Input struct {
	ConvoId string                  `json:"convoId" cborgen:"convoId"`
	Message *ConvoDefs_MessageInput `json:"message" cborgen:"message"`
}

// ConvoSendMessage calls the XRPC method "chat.bsky.convo.sendMessage".
func ConvoSendMessage(ctx context.Context, c lexutil.LexClient, input *ConvoSendMessage_Input) (*ConvoDefs_MessageView, error) {
	var out ConvoDefs_MessageView
	if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "chat.bsky.convo.sendMessage", nil, input, &out); err != nil {
		return nil, err
	}

	return &out, nil
}

The problem is that to use this code, your project has to include indigo as a dependency, and every time there’s an update to anything in that repo, indigo gets a version bump and you get a Dependabot alert for something that’s very probably irrelevant, but you have no way to know that without manually checking the indigo repo, so you’ll probably just make the update and litter your commit history with meaningless revisions.

lexgen uses a clumsy representation for unions
#

This isn’t the biggest flaw in lexgen, but it’s disappointing to find that unions are represented in its generated code by big structs with fields for every possible union type value. Here’s an example from api/bsky/actordefs.go.

type ActorDefs_Preferences_Elem struct {
	ActorDefs_AdultContentPref            *ActorDefs_AdultContentPref
	ActorDefs_ContentLabelPref            *ActorDefs_ContentLabelPref
	ActorDefs_SavedFeedsPref              *ActorDefs_SavedFeedsPref
	ActorDefs_SavedFeedsPrefV2            *ActorDefs_SavedFeedsPrefV2
	ActorDefs_PersonalDetailsPref         *ActorDefs_PersonalDetailsPref
	ActorDefs_FeedViewPref                *ActorDefs_FeedViewPref
	ActorDefs_ThreadViewPref              *ActorDefs_ThreadViewPref
	ActorDefs_InterestsPref               *ActorDefs_InterestsPref
	ActorDefs_MutedWordsPref              *ActorDefs_MutedWordsPref
	ActorDefs_HiddenPostsPref             *ActorDefs_HiddenPostsPref
	ActorDefs_BskyAppStatePref            *ActorDefs_BskyAppStatePref
	ActorDefs_LabelersPref                *ActorDefs_LabelersPref
	ActorDefs_PostInteractionSettingsPref *ActorDefs_PostInteractionSettingsPref
	ActorDefs_VerificationPrefs           *ActorDefs_VerificationPrefs
}

One convenient thing about this is that it’s easy to know how to set this – just set the field corresponding to the union subtype that I want to set. But what if I accidentally set more than one subfield? And how big do these structures need to be? Again, it’s not the most flawed thing to find in generated code, but I think t could be done better (we’ll discuss that below).

What slink does differently#

Unlike the lexgen generator, slink started with the needs of application developers. It’s designed to be the easiest and best way to build and maintain Go programs that use XRPC.

slink generates one package: xrpc#

This means that the namespace is flat, so names are long, but they are explicit and unambiguous. There are no cross-package dependencies to resolve because everything is in a single package. Can there be a lot of code in this package? Maybe, but only if your application calls a lot of different methods or you don’t use a manifest to focus your code generation.

Here’s the slink-generated code to send a chat message:

// Code generated ... DO NOT EDIT.

// Package xrpc is generated from Lexicon source files by slink.
// Get slink at https://github.com/agentio/slink.
package xrpc // chat.bsky.convo.sendMessage

import (
	"context"

	"github.com/agentio/slink/pkg/slink"
)

const ChatBskyConvoSendMessage_Description = ""

const ChatBskyConvoSendMessage_Input_Description = "Input for ChatBskyConvoSendMessage"

type ChatBskyConvoSendMessage_Input struct {
	ConvoId string                          `json:"convoId"`
	Message *ChatBskyConvoDefs_MessageInput `json:"message,omitempty"`
}

type ChatBskyConvoSendMessage_Output = ChatBskyConvoDefs_MessageView

func ChatBskyConvoSendMessage(ctx context.Context, c slink.Client, input *ChatBskyConvoSendMessage_Input) (*ChatBskyConvoSendMessage_Output, error) {
	var output ChatBskyConvoSendMessage_Output
	if err := c.Do(ctx, slink.Procedure, "application/json", "chat.bsky.convo.sendMessage", nil, input, &output); err != nil {
		return nil, err
	}
	return &output, nil
}

(Note that the description above is empty because there is no description in the Lexicon sources for this method.)

Here’s what it looks like to call this method:

c = froda.NewClientWithOptions(froda.ClientOptions{
	Host:          fromDidDocument.Service[0].ServiceEndpoint,
	Authorization: "Bearer " + result.AccessJwt,
	ATProtoProxy:  "did:web:api.bsky.chat#bsky_chat",
})
...
result, err := xrpc.ChatBskyConvoSendMessage(cmd.Context(), c, &xrpc.ChatBskyConvoSendMessage_Input{
	ConvoId: convoId,
	Message: &xrpc.ChatBskyConvoDefs_MessageInput{
		Text: text,
	},
})

Here froda (“wise with experience”) is the default client that is bundled with slink. It conforms to an interface so you can easily replace it, but it will probably do what you need.

Your opinion may vary, but to me, the extra verbosity of names like ChatBskyConvoSendMessage eliminates confusion and doesn’t get in my way at all as I write and read this code.

slink only generates code that you use#

If you provide an optional manifest that lists XRPC methods, slink only generates the handlers and structs needed to call those methods.

{
  "ids": [
	"com.atproto.server.createSession",
	"chat.bsky.convo.getConvoForMembers",
	"chat.bsky.convo.sendMessage"
  ]
}

When we use this in the chatter project, slink generates these files:

VS Code sidebar showing files generated for the chatter project

slink leaves CBOR for infrastructure projects#

CBOR encoding is an important part of ATProto, but it’s not necessary for most ATProto applications, and so to keep those simple, it’s intentionally left out of slink. That doesn’t mean that it’s impossible to use CBOR with slink-generated data structures, it just moves all of the CBOR complexity outside of slink. CBOR code generators can use the json tags produced by slink, and dynamic CBOR generation is also possible by tripping through JSON, i.e converting struct to JSON, converting the JSON to generic Go types, and converting those to CBOR using a standard CBOR package like fxamacker/cbor. I have another project cooking that uses a custom CBOR generator for this and it’s been good enough to build a PDS with a working subscribeRepos feed.

slink generates a CLI#

You can use the slink CLI to explore AT Protocol methods and call them directly. Here’s a glimpse of what you’ll see running slink:

$ slink

"Perhaps we’ve shaken him off at last, the miserable slinker!"

A tool for working with the AT Protocol.

Environment Variables:
  SLINK_HOST sets the target host (e.g. "https://public.api.bsky.app").
  SLINK_AUTH sets the authorization header (e.g. "Bearer XXXX").
  SLINK_ATPROTOPROXY sets the atproto-proxy header.
  SLINK_PROXYSESSION sets the proxy-session header (used by IO).
  SLINK_USERDID sets the user-did header (used by IO).

Usage:
  slink [command]

Available Commands:
  call        Call XRPC methods
  check       Check XRPC records
  completion  Generate the autocompletion script for the specified shell
  generate    Generate slink code from a directory of Lexicon files
  help        Help about any command
  resolve     Resolve atproto identifiers
  token       Operate on tokens

Flags:
  -h, --help   help for slink

Use "slink [command] --help" for more information about a command.
$ slink call
Call XRPC methods

Usage:
  slink call [command]

Available Commands:
  app.bsky.actor            Call XRPC methods under app.bsky.actor
  app.bsky.ageassurance     Call XRPC methods under app.bsky.ageassurance
  app.bsky.bookmark         Call XRPC methods under app.bsky.bookmark
  app.bsky.contact          Call XRPC methods under app.bsky.contact
  app.bsky.draft            Call XRPC methods under app.bsky.draft
  app.bsky.feed             Call XRPC methods under app.bsky.feed
  app.bsky.graph            Call XRPC methods under app.bsky.graph
  app.bsky.labeler          Call XRPC methods under app.bsky.labeler
  app.bsky.notification     Call XRPC methods under app.bsky.notification
  app.bsky.unspecced        Call XRPC methods under app.bsky.unspecced
  app.bsky.video            Call XRPC methods under app.bsky.video
  at.noted.words            Call XRPC methods under at.noted.words
  chat.bsky.actor           Call XRPC methods under chat.bsky.actor
  chat.bsky.convo           Call XRPC methods under chat.bsky.convo
  chat.bsky.moderation      Call XRPC methods under chat.bsky.moderation
  com.atproto.admin         Call XRPC methods under com.atproto.admin
  com.atproto.identity      Call XRPC methods under com.atproto.identity
  com.atproto.label         Call XRPC methods under com.atproto.label
  com.atproto.lexicon       Call XRPC methods under com.atproto.lexicon
  com.atproto.moderation    Call XRPC methods under com.atproto.moderation
  com.atproto.repo          Call XRPC methods under com.atproto.repo
  com.atproto.server        Call XRPC methods under com.atproto.server
  com.atproto.sync          Call XRPC methods under com.atproto.sync
  com.atproto.temp          Call XRPC methods under com.atproto.temp
  tools.ozone.communication Call XRPC methods under tools.ozone.communication
  tools.ozone.hosting       Call XRPC methods under tools.ozone.hosting
  tools.ozone.moderation    Call XRPC methods under tools.ozone.moderation
  tools.ozone.safelink      Call XRPC methods under tools.ozone.safelink
  tools.ozone.server        Call XRPC methods under tools.ozone.server
  tools.ozone.set           Call XRPC methods under tools.ozone.set
  tools.ozone.setting       Call XRPC methods under tools.ozone.setting
  tools.ozone.signature     Call XRPC methods under tools.ozone.signature
  tools.ozone.team          Call XRPC methods under tools.ozone.team
  tools.ozone.verification  Call XRPC methods under tools.ozone.verification

Flags:
  -h, --help   help for call

Use "slink call [command] --help" for more information about a command.
$ slink call com.atproto.sync
Call XRPC methods under com.atproto.sync

Usage:
  slink call com.atproto.sync [command]

Available Commands:
  get-blob                 Get a blob associated with a given account. Returns the full blob as original...
  get-blocks               Get data blocks from a given repo, by CID. For example, intermediate MST node...
  get-checkout             DEPRECATED - please use com.atproto.sync.getRepo instead
  get-head                 DEPRECATED - please use com.atproto.sync.getLatestCommit instead
  get-host-status          Returns information about a specified upstream host, as consumed by the serve...
  get-latest-commit        Get the current commit CID & revision of the specified repo. Does not require...
  get-record               Get data blocks needed to prove the existence or non-existence of record in t...
  get-repo                 Download a repository export as CAR file. Optionally only a 'diff' since a pr...
  get-repo-status          Get the hosting status for a repository, on this server. Expected to be imple...
  list-blobs               List blob CIDs for an account, since some repo revision. Does not require aut...
  list-hosts               Enumerates upstream hosts (eg, PDS or relay instances) that this service cons...
  list-repos               Enumerates all the DID, rev, and commit CID for all repos hosted by this serv...
  list-repos-by-collection Enumerates all the DIDs which have records with the given collection NSID.
  notify-of-update         Notify a crawling service of a recent update, and that crawling should resume...
  request-crawl            Request a service to persistently crawl hosted repos. Expected use is new PDS...

Flags:
  -h, --help   help for com.atproto.sync

Use "slink call com.atproto.sync [command] --help" for more information about a command.
$ slink call com.atproto.sync list-blobs -h
List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.

Usage:
  slink call com.atproto.sync list-blobs [flags]

Flags:
      --cursor string   cursor
      --did string      did
  -h, --help            help for list-blobs
      --limit int       limit (default 500)
  -l, --log string      log level (debug, info, warn, error, fatal) (default "warn")
  -o, --output string   output destination
      --since string    since

The help information is all pulled from the Lexicon source.

The slink CLI has no file-based state#

Unlike other CLIs, slink stores and reads no authentication info in local files. Instead, everything is either read from direct code-level configuration or (for the CLI), from environment variables. This is for security: do what you want with your secrets, but slink will never put them in a file where they can leak.

The default slink client can call abstract sockets as well as TCP-based ones, and it is configurable with values that specify the host, the authorization header, the atproto-proxy header, and identity headers that an upstream proxy (ahem, IO) can use to add credentials to API calls that you send with slink.

  SLINK_HOST sets the target host (e.g. "https://public.api.bsky.app").
  SLINK_AUTH sets the authorization header (e.g. "Bearer XXXX").
  SLINK_ATPROTOPROXY sets the atproto-proxy header.
  SLINK_PROXYSESSION sets the proxy-session header (used by IO).
  SLINK_USERDID sets the user-did header (used by IO).

slink has a better representation of unions#

Here’s the slink implementation of the preferences union that we mentioned earlier. If you’ve worked with Protocol Buffer unions in Go generated code, it should look familiar:

// AppBskyActorDefs_Preferences_Elem is a union with these possible values:
// - AppBskyActorDefs_AdultContentPref (app.bsky.actor.defs#adultContentPref)
// - AppBskyActorDefs_ContentLabelPref (app.bsky.actor.defs#contentLabelPref)
// - AppBskyActorDefs_SavedFeedsPref (app.bsky.actor.defs#savedFeedsPref)
// - AppBskyActorDefs_SavedFeedsPrefV2 (app.bsky.actor.defs#savedFeedsPrefV2)
// - AppBskyActorDefs_PersonalDetailsPref (app.bsky.actor.defs#personalDetailsPref)
// - AppBskyActorDefs_DeclaredAgePref (app.bsky.actor.defs#declaredAgePref)
// - AppBskyActorDefs_FeedViewPref (app.bsky.actor.defs#feedViewPref)
// - AppBskyActorDefs_ThreadViewPref (app.bsky.actor.defs#threadViewPref)
// - AppBskyActorDefs_InterestsPref (app.bsky.actor.defs#interestsPref)
// - AppBskyActorDefs_MutedWordsPref (app.bsky.actor.defs#mutedWordsPref)
// - AppBskyActorDefs_HiddenPostsPref (app.bsky.actor.defs#hiddenPostsPref)
// - AppBskyActorDefs_BskyAppStatePref (app.bsky.actor.defs#bskyAppStatePref)
// - AppBskyActorDefs_LabelersPref (app.bsky.actor.defs#labelersPref)
// - AppBskyActorDefs_PostInteractionSettingsPref (app.bsky.actor.defs#postInteractionSettingsPref)
// - AppBskyActorDefs_VerificationPrefs (app.bsky.actor.defs#verificationPrefs)
// - AppBskyActorDefs_LiveEventPreferences (app.bsky.actor.defs#liveEventPreferences)
type AppBskyActorDefs_Preferences_Elem struct {
	Wrapper AppBskyActorDefs_Preferences_Elem_Wrapper
}

// Value wrappers must conform to AppBskyActorDefs_Preferences_Elem_Wrapper
type AppBskyActorDefs_Preferences_Elem_Wrapper interface {
	isAppBskyActorDefs_Preferences_Elem()
}

Here we say that the preference element can have exactly one value, a type that implements the (type)_Wrapper interface. Wrapper types are generated for each possible union type. Here’s one:

// AppBskyActorDefs_Preferences_Elem__AppBskyActorDefs_MutedWordsPref wraps values of type *AppBskyActorDefs_MutedWordsPref
type AppBskyActorDefs_Preferences_Elem__AppBskyActorDefs_MutedWordsPref struct {
	Value *AppBskyActorDefs_MutedWordsPref
}

func (t AppBskyActorDefs_Preferences_Elem__AppBskyActorDefs_MutedWordsPref) isAppBskyActorDefs_Preferences_Elem() {

Here’s how we set that enum in code:

elem := &xrpc.AppBskyActorDefs_Preferences_Elem{
	Wrapper: xrpc.AppBskyActorDefs_Preferences_Elem__AppBskyActorDefs_MutedWordsPref{
		Value: &xrpc.AppBskyActorDefs_MutedWordsPref{
			Items: []*xrpc.AppBskyActorDefs_MutedWord{
				{Value: "atproto"},
				{Value: "bluesky"},
				{Value: "lexicon"},
			},
		},
	},
}

It’s obviously not concise! But it’s surprisingly easy to write this code in an autocompleting text editor, and it’s completely unambiguous. The union can only be set to one value and it’s easy to read with a type switch (not shown).

How it works: let’s send a chat message
#

Use the slink CLI to explore the Bluesky Chat API#

I’ll show you this with a shell script. Pull it apart to see how you can use slink to call

#!/bin/sh
# The sender, receiver, and sender's password are required arguments.
SENDER=$1
RECEIVER=$2
PASSWORD=$3

# First we get the sender's PDS.
export SLINK_HOST=$(slink resolve did $(slink resolve handle $SENDER) --pds)

# Then we call com.atproto.server.createSessionn to get an auth token.
export SLINK_AUTH="Bearer $(slink call com.atproto.server create-session \
		--identifier $SENDER \
		--password $PASSWORD \
		| jq .accessJwt -r)"

# chat.bsky.convo.getConvoForMembers creates a chat "conversation".
export CONVOID=$(SLINK_ATPROTOPROXY=did:web:api.bsky.chat#bsky_chat \
	slink call chat.bsky.convo get-convo-for-members \
	--members $(slink resolve handle $SENDER) \
	--members $(slink resolve handle $RECEIVER) \
	| jq .convo.id -r)

# Use chat.bsky.convo.sendMessage to send a message.
SLINK_ATPROTOPROXY=did:web:api.bsky.chat#bsky_chat \
	slink call chat.bsky.convo send-message \
	--convo-id $CONVOID \
	--message message.json

Here’s message.json:

{"text": "I built a mechanically-generated CLI that calls XRPC functions. I'm using it to send you this with chat.bsky.convo.sendMessage"}

And here’s what I get when I run the script:

{
  "id": "3mgnkdgy6yk2g",
  "rev": "2222224ej6uph",
  "sender": {
    "did": "did:plc:ahr5yhciwadehhwm7fotyfju"
  },
  "sentAt": "2026-03-09T19:04:34.434Z",
  "text": "I built a mechanically-generated CLI that calls XRPC functions. I'm using it to send you this with chat.bsky.convo.sendMessage"
}
Bluesky app screenshot showing the chat messsage that I sent with slink

Use a slink-generated client library in a Go program#

The agentio/chatter repo does this directly from Go using a slink-generated client library. I’ll let you read the code – it’s the best explanation of the way this all fits together. The Makefile and Go action configuration show how slink is used to generate the client library.

Recommended Practices#

I’ve built several projects now with slink and in the process, I’ve come up with some recommendations for how to best use slink.

Import lexicons as a Git module
#

Include a Lexicons repo as a Git module. To make these imports lightweight, I’ve set up a separate repo that just contains lexicons. It will need to be updated as the upstream sources change, so be careful with this, and you might prefer to create your own lexicons repos that contain extra lexicons that you want to use, and of course you could directly check lexicon sources into your project repos.

Use a manifest
#

Write a manifest and use it to generate your client. I generally call my manifests xrpc.json and I keep them in the root directory of my projects.

Generate your client libraries at build time
#

Generally I don’t check client libraries into my repos. This has one downside: go install is unable to build binaries in these repos because it can’t generate code (without //go:generate build directives, possibly a subject of a future update to this post).

Don’t mix client libraries from different sources
#

Finally, never ever (ever) do this! It’s a recipe for a dependency nightmare, because those client libraries will have dependencies that will often be out of sync. Only use client libraries that you generate yourself, and for XRPC, you can generate them all with slink.

Conclusion: use slink and tell me about it!#

As a Go developer, I’ve really enjoyed my liberation from indigo-saddled development. My projects are lighter, build faster, and I find that everything about them is easier to understand.

I think this might be true for you, too, and I would appreciate hearing feedback from anyone who tries slink.

Also, I’m actively tweaking slink as I use it in my projects and will feel more or less free to make breaking changes depending on who’s affected. So please do let me know if you depend on it!