Building onboarding into your CLI with Dopt thumbnail
Engineering

Building onboarding into your CLI with Dopt

Joe McKenney's headshotJoe McKenney
  • October 4, 2023
  • 5 min read

Dopt in a CLI

Since starting Dopt, we’ve met with companies of varying shapes/sizes, a number of which have presented us with use cases for product onboarding and education in non-traditional SaaS surface areas, e.g., things like browser extensions or CLIs.

This has really driven the point home that SaaS isn’t always a webapp; user journeys often cut across many surface areas. Developer tools stick out the most here, where the webapp is often a small piece of the overall journey, with users spending larger amounts of time in docs surfaces or using open source tools like CLIs.

We get jazzed ✨ when we hear about these use cases. Why? Because we built Dopt to be flexible, but not in the Twitter-sphere sense of the word e.g., a React component with customizable styles. Flexibility, for us, means APIs and SDKs to meet your users on their journey, independent of where that journey takes them.

So, to showcase what flexibility can buy you, we built an example SaaS app and CLI with Dopt-powered onboarding.

This blog post will walk through Cucumber, a hypothetical API doc hosting platform with version control and collaboration features. The platform is comprised of a webapp and a CLI, aptly named cucumber. Dopt is used to build a cohesive onboarding experience that spans across both webapp and CLI surfaces.

We built it in a few hours because Dopt makes hard problems like this no sweat 💦.


Cucumber is a hypothetical API doc hosting platform with version control and collaboration features. It lets you and others work together on API docs from anywhere. Your docs can be managed, edited, and deployed from the platform or in code. For the latter, cucumber provides a simple and modern CLI.

The CLI is where Cucumber users spend most of their time. It allows for the integration of cucumber into CI workflows, keeping your API docs in sync as their definition changes. While the mechanisms for managing your API docs with/ Cucumber are familiar (think GitHub), folks often run into friction getting up and running with the CLI.

To solve this, Cucumber wanted to add helpful task-oriented messages along the user’s first-time journey through the CLI, prompting them to use a subsequent CLI command that will move them towards activation, i.e., deploy API docs for the first time.

Let’s explore how we build this with Dopt.

To start, we built out a flow in Dopt. The flow can help you target the right users, map out the states we’ll leverage when building the experience, and manage content that will be shown to the users.

A first pass at the flow looked like this.

Onboarding flow

The start block targets all users who signed up for the product and indicated they were developers.

Moving down, we have a custom block for installation and login with the CLI. It has with two paths coming out of it, a yes_help and no_help path. This is to account for users who have multiple accounts (e.g., personal and work) and may have used the CLI before.

If they haven’t used cucumber, they enter a checklist block that contains 5 steps. Each step maps to a CLI command, representing whether this user has used the command and encapsulating helpful task-oriented messages that are shown on the user’s first-time journey through the CLI, prompting them to use a subsequent CLI command that will move them towards activation.

Checklist component block panel

Having defined the flow, you can now tie the states of the blocks in the flow to the experiences you want to show users in code, e.g., Cucumber can use the state of a checklist item or the login block to determine whether or not to show a message to the user in the CLI.

Leverage states in code is simple. Every block in the flow is addressable via a semantic identifier that you define. You can use our SDK to access state and content on blocks. You can tie those states to a moment in the user’s journey, e.g., showing a message in the console or maybe the user clicking a button in your webapp. Once the task associated with the state has been completed, transition the state of the block to signal the user shouldn’t see this message again. See the pseudo-code below.

// Instantiate the client with your API key, the current user, and the// flow versions you'll be accessingconst client new Dopt({  apiKey: process.env.BLOCKS_API_KEY as string,  userId,  flowVersions: { 'getting-started-with-cucumber': 1 },});
// Wait for flow initializationawait client.flow('getting-started-with-cucumber').initialized();
// Access a particular blockconst block = client.block<['complete']>('dev-platform-example.login');
// Show a helpful message based on the state of the blockif (block.state.active) {  console.log('Welcome to the Cucumber CLI 🥒💻!');  console.log('We make managing your developer docs a breeze.');  console.log('First things first, run the "pull" command, e.g.,')  console.log('$ cucumber pull latest')
	// Transition the block, conceptually moving the user to the  // next step in the flow  block.transition('complete');}

✨ We offer a feature-rich JavaScript SDK that allows for easy access from any JavaScript runtime (e.g. the browser or Node). Want something lighter? We offer thing client libraries for working with our API more directly (Browser, Node.js, Python). Lastly, if we don’t currently offer an SDK or Client in the language you’re working in, you can use our API directly. See our API docs here!

Great! Once you’ve shipped your flow, Dopt makes iteration a whole lot easier.

Want to update the messages you are showing to users in the CLI? Easy, update the content in Dopt and deploy it immediately. No code push is necessary.

Field editing

Want to understand how your onboarding is performing. Leverage our flow analytics to do some ad-hoc analysis on how users are moving through your flow.

Flow analytics

Lastly, larger structural changes to this flow, maybe as a result of learning from your analysis, are made easy with Dopt. We’ll provide simple and intuitive algorithms for migrating users across flow versions automatically.

User state migration

Here’s a five minute demo of the end to end experience.


If you’re interested in how i built this…

I used https://github.com/unjs/citty to build the CLI, because the stuff the unjs folks put is killer, and we’ve been using it for all of our internal CLIs recently, so why not showcase what we ❤️.

The CLI definition is quite simple. It defines some metadata as well as the various commands the CLI offers.

#! /usr/bin/env node
import { defineCommand, runMain } from 'citty';import { version, description } from '../package.json';
const main = defineCommand({  meta: {    name: 'cucumber',    version,    description,  },  args: {},  subCommands: {    login: () => import('./commands/login').then((r) => r.default),    logout: () => import('./commands/logout').then((r) => r.default),    pull: () => import('./commands/pull').then((r) => r.default),    preview: () => import('./commands/preview').then((r) => r.default),    deploy: () => import('./commands/deploy').then((r) => r.default),  },});
runMain(main);

The commands also define some metadata as well as three lifecycle methods setup, run, and cleanup. These methods share a common context, scoped to this command.

import { defineCommand } from 'citty';
export default defineCommand({  meta: {    name: 'logout',    description: 'Unauthenticate from the CLI',  },  async setup(context) {    /* ... */  },  async run(context) {    /* ... */  },  async cleanup(context) {    /* ... */  },});

With the CLI defined and the commands scaffolded out, we immediately get great help text out of the box!

CLI help text

Below is the annotated code for the pull command. Things to note.

  • Use setup to do some basic authentication, validation, and initialization of the Dopt SDK
  • Use run to define the guts of the command (redacted here for simplicity) + access Dopt states to conditionally show messages to users.
    • In this case, if the checklist item block associated with the pull command is active, print the contents of fields on the block that were configured in Dopt.
    • Once messages have been printed, complete the block so these messages aren’t shown again.
  • Use clean to close the connection made from Dopt’s SDK
import { defineCommand } from 'citty';import { getUserForAuthenticatedCommand } from '../store';import { initializeDopt } from '../dopt';
import { timeout, log } from '../utils';
export default defineCommand({  meta: {    name: 'pull',    description: 'Pull down your API docs and start editing.',  },  async setup({ data = {} }) {    // Get the currently authenticated user    const user = await getUserForAuthenticatedCommand('pull');
    // Initialize Dopt's JavaScript SDK    const doptSdk = await initializeDopt(user);
    // Grab the checklist item corresponding to this CLI command    const checklistItem = doptSdk.checklistItem('dev-platform-example.pull');
    // Decorate the command's context.    data.doptSdk = doptSdk;    data.checklistItem = checklistItem;  },  async run(context) {    // [REDACTED] Cucumber's logic for pull docs down from the platform
    /*     * Dopt integrated into the CLI.     *     * Use the checklist item's state to determine whether     * to show messages configured in Dopt to the user.     */    const checklistItem = context.data.checklistItem;    if (checklistItem && checklistItem.state.active) {      await timeout(50);      await log(checklistItem.field<string>('pull-message-1'));      await timeout(50);      await log(checklistItem.field<string>('pull-message-2'));      await timeout(50);      await log(checklistItem.field<string>('pull-message-3'));
      // Complete the checklist item. The currently authenticated      // user will not see this message again and the in-app      // checklist will update in real-time.      checklistItem.complete();    }  },
  async cleanup(context) {    // Close the connection to Dopt    context.doptSdk.destroy();  },});