API-Controlled Alfred Workflows: An Experiment

Can we write a bare-bones Alfred workflow and have a remote API make all of the decisions? Maybe.

Joe Tannenbaum

I am currently building out a complicated, multi-step Alfred workflow. I built the whole thing conventionally using workflow objects and scripts (using the excellent AwGo), then wired it all up to my API. In the end, it looked... complicated. When I finished my first thought was: "This doesn't feel maintainable. I'm going to be afraid to touch this if I leave it for too long."

This is... a lot.
This is... a lot.

Then this idea popped into my head, a scratch that needed itching: The logic is already all in my API. Can I just power this whole workflow right from there? I would love to just return JSON that Alfred understands right from the API and have the workflow do the least amount of interpretation possible, just serve as an input collector and let the API make all of the decisions.

Some Context

To give context: The workflow is for adding IP whitelist rules in Blip. If you're building the rule from scratch and not using a preset, the bare minimum number of steps are:

  • Kick off the workflow with addmyip (adds your current IP to a firewall)

  • Select your provider

  • Select your resource

  • Select a port and protocol

  • Select an expiration time

If you'd like to add a custom IP address, you kick off the workflow with an input that allows you to enter it. If you select a provider that needs a region before we can list its resources (such as AWS), we need to show you that list first. If you choose a custom expiration time (not from the pre-defined list), we present you with an input to enter that. You get the idea. There are a fair number of conditions and routing happening on the Alfred side.

The Concept

Here's the ideal scenario: the Alfred workflow should just be a small, recursive workflow that simply tells that API what it has so far and asks if there's anything else it needs. It routes to the correct type of input based on the API response, collects the input, then repeats the process. Something like this:

Sample recursive workflow based on Blip's needs
Sample recursive workflow based on Blip's needs

This would rely heavily on Alfred's workflow variables to build and maintain the payload as we went along. Variables would also be responsible for signaling the type of input we need and whether or not we were done collecting information (stopping the recursion).

How do we handle the recursion? External triggers! Define a trigger at the start of the workflow and call it at the end to start all over again.

Let's give this thing a whirl.

Proof of Concept

Should be noted: None of what follows is production-ready code. This is merely a proof of concept.

The Alfred Scripts

Ok! Let's get into it. The Go side of this is very simple, that's the point. The Run Script object from the diagram above simply runs this command:

1./alfred-recursive fetch

The Script Filter object runs this command:

1./alfred-recursive scriptFilter

Cool. Let's check out the Go script:

1package main
2 
3import (
4 "bytes"
5 "encoding/json"
6 "io"
7 "net/http"
8 "os"
9 
10 aw "github.com/deanishe/awgo"
11)
12 
13var (
14 wf *aw.Workflow
15)
16 
17func init() {
18 wf = aw.New()
19}
20 
21func run() {
22 command := wf.Args()[0]
23 
24 if command == "scriptFilter" {
25 // If we're in the Script Filter, the API has already given us the items
26 // it wants to return as a JSON encoded value, just send it back to Alfred
27 os.Stdout.Write([]byte(wf.Config.Get("items")))
28 return
29 }
30 
31 fetchFromApi()
32}
33 
34func fetchFromApi() {
35 // For local testing, allow us to override the API
36 // url with a non-exported config variable
37 apiUrl := wf.Config.Get("apiUrl", "https://ipblip.com")
38 
39 jsonMap := map[string]string{
40 "params": wf.Config.Get("params"),
41 "fieldName": wf.Config.Get("fieldName"),
42 "fieldValue": wf.Config.Get("fieldValue"),
43 }
44 
45 jsonData, err := json.Marshal(jsonMap)
46 
47 if err != nil {
48 wf.FatalError(err)
49 }
50 
51 bodyReader := bytes.NewReader(jsonData)
52 
53 req, err := http.NewRequest(http.MethodPost, apiUrl+"/api/alfred", bodyReader)
54 
55 if err != nil {
56 wf.FatalError(err)
57 }
58 
59 req.Header.Set("Content-Type", "application/json")
60 req.Header.Set("Accept", "application/json")
61 req.Header.Set("Authorization", "Bearer "+wf.Config.Get("apiKey"))
62 req.Header.Set("Blip-Integration", "alfred")
63 // Send the workflow and Alfred versions as headers in case
64 // we ever need to take it into account on the API side
65 req.Header.Set("Alfred-Workflow-Version", wf.Version())
66 req.Header.Set("Alfred-Version", wf.Config.Get(aw.EnvVarAlfredVersion))
67 
68 res, resError := http.DefaultClient.Do(req)
69 
70 if resError != nil {
71 wf.FatalError(resError)
72 }
73 
74 resBody, readError := io.ReadAll(res.Body)
75 
76 if readError != nil {
77 wf.FatalError(readError)
78 }
79 
80 apiData := map[string]string{}
81 
82 jsonError := json.Unmarshal([]byte(resBody), &apiData)
83 
84 if jsonError != nil {
85 wf.FatalError(jsonError)
86 }
87 
88 // The API has already provided us with Alfred-ready JSON as the output,
89 // just send it right back to Alfred
90 os.Stdout.Write([]byte(apiData["output"]))
91}
92 
93func main() {
94 wf.Run(run)
95}

The API Side

I'm using Laravel to power the API, along with my Alfred PHP Workflow library to generate the Alfred JSON. The basic concept:

  • When we receive a request, the params field will be a JSON-encoded payload of all of the information we've collected so far.

  • We check the payload for the fields we need, in order. Once we find one we don't have, we send back the appropriate JSON to collect that information from the user.

  • We then add the collected information to the payload for the next request.

Let's take a look at this in action in our controller. The build method handles the request:

1<?php
2 
3namespace App\Http\Controllers;
4 
5use Alfred\Workflows\Workflow;
6use App\Models\Provider;
7use Illuminate\Http\Request;
8use Illuminate\Support\Collection;
9 
10class AlfredRecursiveController extends Controller
11{
12 protected Collection $params;
13 
14 protected Workflow $wf;
15 
16 public function build(Request $request)
17 {
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);
19 
20 $this->wf = new Workflow();
21 
22 if (!$this->hasParam('provider_id')) {
23 $request->user()->providers->each(function (Provider $p) {
24 $this->wf
25 ->items()
26 ->add()
27 ->title($p->name)
28 ->subtitle($p->label)
29 ->arg($p->hashid)
30 ->icon("images/{$p->provider->value}.png")
31 // If they select this item, set the new "params" Alfred variable
32 // to include this value as well
33 ->variable(
34 'params',
35 $this->params
36 ->pipe(function ($a) use ($p) {
37 $a->offsetSet('provider_id', $p->hashid);
38 
39 return $a;
40 })
41 ->toJson()
42 );
43 });
44 
45 return [
46 'output' => $this->wf->setFromRunScript()->variables([
47 'items' => $this->wf->output(false),
48 ])->output(false),
49 ];
50 }
51 }
52 
53 protected function hasParam($key)
54 {
55 return $this->params->offsetExists($key);
56 }
57}

Initial Result

Ok great! What does this get us so far? Let's see:

  • The Run Script object runs fetch and gets the response from the API, outputs the output from above directly to Alfred

  • Next up is the conditional:

We haven't even set the done or type variable, so we default to the Script Filter

  • The Script Filter runs the scriptFilter command of our Go script, which simply echos out the items returned by the API:

When the user selects an item, it overwrites the params variable (as noted above in the PHP script) and we head back to the beginning!

It should be noted that in order to get searching to work, you have to check off "Alfred filters results" in the Script Filter object.

Collecting a Custom Input

So far, so good. We are now able to select items from an API-provided list and save them. But what if we need custom input from the user? Let's lean on workflow variables again.

Let's add a check for the ip_address field below our provider_id check:

1<?php
2 
3namespace App\Http\Controllers;
4 
5use Alfred\Workflows\Workflow;
6use App\Models\Provider;
7use Illuminate\Http\Request;
8use Illuminate\Support\Collection;
9 
10class AlfredRecursiveController extends Controller
11{
12 protected Collection $params;
13 
14 protected Workflow $wf;
15 
16 public function build(Request $request)
17 { ...
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);
19 
20 $this->wf = new Workflow();
21 
22 if (!$this->hasParam('provider_id')) {
23 $request->user()->providers->each(function (Provider $p) {
24 $this->wf
25 ->items()
26 ->add()
27 ->title($p->name)
28 ->subtitle($p->label)
29 ->arg($p->hashid)
30 ->icon("images/{$p->provider->value}.png")
31 // If they select this item, set the new "params" Alfred variable
32 // to include this value as well
33 ->variable(
34 'params',
35 $this->params
36 ->pipe(function ($a) use ($p) {
37 $a->offsetSet('provider_id', $p->hashid);
38 
39 return $a;
40 })
41 ->toJson()
42 );
43 });
44 
45 return [
46 'output' => $this->wf->setFromRunScript()->variables([
47 'items' => $this->wf->output(false),
48 ])->output(false),
49 ];
50 }
51 
52 if (!$this->hasParam('ip_address')) {
53 return [
54 'output' => $this->setVariables([
55 'type' => 'userInput',
56 'inputTitle' => 'Enter the IP address you\'d like to authorize',
57 'inputSubtitle' => 'e.g. 130.196.136.185',
58 'fieldName' => 'ip_address',
59 ])->output(false),
60 ];
61 }
62 }
63 
64 protected function hasParam($key)
65 {
66 return $this->params->offsetExists($key);
67 }
68}

setVariables is a helper method that by default nulls out any potentially re-usable variables in our workflow so that we start fresh on each recursion and overwrites them if necessary. It looks like this:

1<?php
2 
3namespace App\Http\Controllers;
4 
5use Alfred\Workflows\Workflow;
6use App\Models\Provider;
7use Illuminate\Http\Request;
8use Illuminate\Support\Collection;
9 
10class AlfredRecursiveController extends Controller
11{
12 protected Collection $params;
13 
14 protected Workflow $wf;
15 
16 public function build(Request $request)
17 { ...
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);
19 
20 $this->wf = new Workflow();
21 
22 if (!$this->hasParam('provider_id')) {
23 $request->user()->providers->each(function (Provider $p) {
24 $this->wf
25 ->items()
26 ->add()
27 ->title($p->name)
28 ->subtitle($p->label)
29 ->arg($p->hashid)
30 ->icon("images/{$p->provider->value}.png")
31 // If they select this item, set the new "params" Alfred variable
32 // to include this value as well
33 ->variable(
34 'params',
35 $this->params
36 ->pipe(function ($a) use ($p) {
37 $a->offsetSet('provider_id', $p->hashid);
38 
39 return $a;
40 })
41 ->toJson()
42 );
43 });
44 
45 return [
46 'output' => $this->wf->setFromRunScript()->variables([
47 'items' => $this->wf->output(false),
48 ])->output(false),
49 ];
50 }
51 
52 if (!$this->hasParam('ip_address')) {
53 return [
54 'output' => $this->setVariables([
55 'type' => 'userInput',
56 'inputTitle' => 'Enter the IP address you\'d like to authorize',
57 'inputSubtitle' => 'e.g. 130.196.136.185',
58 'fieldName' => 'ip_address',
59 ])->output(false),
60 ];
61 }
62 }
63 
64 protected function hasParam($key)
65 { ...
66 return $this->params->offsetExists($key);
67 }
68 
69 protected function setVariables(array $variables)
70 {
71 return $this->wf->setFromRunScript()->variables(array_merge([
72 'type' => null,
73 'fieldValue' => null,
74 'fieldName' => null,
75 'params' => $this->params->toJson(),
76 ], $variables));
77 }
78}

This way we don't get any stale variables on the next go-around and we can be confident our workflow conditionals are picking up the right values.

So how does user input work? Let's zoom in on this part of our workflow:

The route when custom user input is needed
The route when custom user input is needed
The Keyword object. We're even setting the Title and Subtitle from the API! (See PHP code above)
The Keyword object. We're even setting the Title and Subtitle from the API! (See PHP code above)
We then set the `fieldValue` variable from the Keyword argument
We then set the `fieldValue` variable from the Keyword argument

So: we collect the user input, set it to the fieldValue variable, and send it back up to the API. As a reminder, these are the fields we are sending to the API with every request from the Go script:

1package main ...
2 
3import (
4 "bytes"
5 "encoding/json"
6 "io"
7 "net/http"
8 "os"
9 
10 aw "github.com/deanishe/awgo"
11)
12 
13var (
14 wf *aw.Workflow
15)
16 
17func init() {
18 wf = aw.New()
19}
20 
21func run() {
22 command := wf.Args()[0]
23 
24 if command == "scriptFilter" {
25 // If we're in the Script Filter, the API has already given us the items
26 // it wants to return as a JSON encoded value, just send it back to Alfred
27 os.Stdout.Write([]byte(wf.Config.Get("items")))
28 return
29 }
30 
31 fetchFromApi()
32}
33 
34func fetchFromApi() {
35 // For local testing, allow us to override the API
36 // url with a non-exported config variable
37 apiUrl := wf.Config.Get("apiUrl", "https://ipblip.com")
38 
39 jsonMap := map[string]string{
40 "params": wf.Config.Get("params"),
41 "fieldName": wf.Config.Get("fieldName"),
42 "fieldValue": wf.Config.Get("fieldValue"),
43 } ...
44 
45 jsonData, err := json.Marshal(jsonMap)
46 
47 if err != nil {
48 wf.FatalError(err)
49 }
50 
51 bodyReader := bytes.NewReader(jsonData)
52 
53 req, err := http.NewRequest(http.MethodPost, apiUrl+"/api/alfred", bodyReader)
54 
55 if err != nil {
56 wf.FatalError(err)
57 }
58 
59 req.Header.Set("Content-Type", "application/json")
60 req.Header.Set("Accept", "application/json")
61 req.Header.Set("Authorization", "Bearer "+wf.Config.Get("apiKey"))
62 req.Header.Set("Blip-Integration", "alfred")
63 // Send the workflow and Alfred versions as headers in case
64 // we ever need to take it into account on the API side
65 req.Header.Set("Alfred-Workflow-Version", wf.Version())
66 req.Header.Set("Alfred-Version", wf.Config.Get(aw.EnvVarAlfredVersion))
67 
68 res, resError := http.DefaultClient.Do(req)
69 
70 if resError != nil {
71 wf.FatalError(resError)
72 }
73 
74 resBody, readError := io.ReadAll(res.Body)
75 
76 if readError != nil {
77 wf.FatalError(readError)
78 }
79 
80 apiData := map[string]string{}
81 
82 jsonError := json.Unmarshal([]byte(resBody), &apiData)
83 
84 if jsonError != nil {
85 wf.FatalError(jsonError)
86 }
87 
88 // The API has already provided us with Alfred-ready JSON as the output,
89 // just send it right back to Alfred
90 os.Stdout.Write([]byte(apiData["output"]))
91}
92 
93func main() {
94 wf.Run(run)
95}

Ok great, we're really on our way here. There's one more thing we have to do on the API side: capture the fieldValue and add it to the params if we receive it. Let's add that now. First, a helper method:

1<?php
2 
3namespace App\Http\Controllers;
4 
5use Alfred\Workflows\Workflow;
6use App\Models\Provider;
7use Illuminate\Http\Request;
8use Illuminate\Support\Collection;
9 
10class AlfredRecursiveController extends Controller
11{
12 protected Collection $params;
13 
14 protected Workflow $wf;
15 
16 public function build(Request $request)
17 { ...
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);
19 
20 $this->wf = new Workflow();
21 
22 if (!$this->hasParam('provider_id')) {
23 $request->user()->providers->each(function (Provider $p) {
24 $this->wf
25 ->items()
26 ->add()
27 ->title($p->name)
28 ->subtitle($p->label)
29 ->arg($p->hashid)
30 ->icon("images/{$p->provider->value}.png")
31 // If they select this item, set the new "params" Alfred variable
32 // to include this value as well
33 ->variable(
34 'params',
35 $this->params
36 ->pipe(function ($a) use ($p) {
37 $a->offsetSet('provider_id', $p->hashid);
38 
39 return $a;
40 })
41 ->toJson()
42 );
43 });
44 
45 return [
46 'output' => $this->wf->setFromRunScript()->variables([
47 'items' => $this->wf->output(false),
48 ])->output(false),
49 ];
50 }
51 
52 if (!$this->hasParam('ip_address')) {
53 return [
54 'output' => $this->setVariables([
55 'type' => 'userInput',
56 'inputTitle' => 'Enter the IP address you\'d like to authorize',
57 'inputSubtitle' => 'e.g. 130.196.136.185',
58 'fieldName' => 'ip_address',
59 ])->output(false),
60 ];
61 }
62 }
63 
64 protected function hasParam($key)
65 { ...
66 return $this->params->offsetExists($key);
67 }
68 
69 protected function setVariables(array $variables) ...
70 {
71 return $this->wf->setFromRunScript()->variables(array_merge([
72 'type' => null,
73 'fieldValue' => null,
74 'fieldName' => null,
75 'params' => $this->params->toJson(),
76 ], $variables));
77 }
78 
79 protected function setParams(array $fields)
80 {
81 foreach ($fields as $field) {
82 if (request()->input('fieldName') === $field && request()->input('fieldValue') !== null) {
83 $this->params->offsetSet($field, request()->input('fieldValue'));
84 }
85 }
86 }
87}

Let's see how it looks:

Capture user input, with the Title and Subtitle customized from the API
Capture user input, with the Title and Subtitle customized from the API

Stopping the Recursion

So at some point, we have all of the information we need, and we should tell Alfred to stop looping the workflow. On the API side, after all of our param checks have passed, we simply add this:

1<?php
2 
3 
4namespace App\Http\Controllers;
5 ...
6use Alfred\Workflows\Workflow;
7use App\Models\Provider;
8use Illuminate\Http\Request;
9use Illuminate\Support\Collection;
10 
11class AlfredRecursiveController extends Controller
12{
13 protected Collection $params;
14 
15 protected Workflow $wf;
16 
17 public function build(Request $request)
18 { ...
19 $this->params = collect(json_decode($request->input('params'), true) ?: []);
20 
21 $this->wf = new Workflow();
22 
23 if (!$this->hasParam('provider_id')) {
24 $request->user()->providers->each(function (Provider $p) {
25 $this->wf
26 ->items()
27 ->add()
28 ->title($p->name)
29 ->subtitle($p->label)
30 ->arg($p->hashid)
31 ->icon("images/{$p->provider->value}.png")
32 // If they select this item, set the new "params" Alfred variable
33 // to include this value as well
34 ->variable(
35 'params',
36 $this->params
37 ->pipe(function ($a) use ($p) {
38 $a->offsetSet('provider_id', $p->hashid);
39 
40 return $a;
41 })
42 ->toJson()
43 );
44 });
45 
46 return [
47 'output' => $this->wf->setFromRunScript()->variables([
48 'items' => $this->wf->output(false),
49 ])->output(false),
50 ];
51 }
52 
53 if (!$this->hasParam('ip_address')) {
54 return [
55 'output' => $this->setVariables([
56 'type' => 'userInput',
57 'inputTitle' => 'Enter the IP address you\'d like to authorize',
58 'inputSubtitle' => 'e.g. 130.196.136.185',
59 'fieldName' => 'ip_address',
60 ])->output(false),
61 ];
62 }
63 
64 // This is where you'd save the record or
65 // do whatever work needs to be done
66 return [
67 'output' => $this->setVariables([
68 'done' => true,
69 'notificationTitle' => 'IP Authorized',
70 'notificationBody' => $this->params->offsetGet('ip_address') . ' has been authorized in AWS',
71 ])->output(false),
72 ];
73 }
74 
75 protected function hasParam($key)
76 { ...
77 return $this->params->offsetExists($key);
78 }
79 
80 protected function setVariables(array $variables) ...
81 {
82 return $this->wf->setFromRunScript()->variables(array_merge([
83 'type' => null,
84 'fieldValue' => null,
85 'fieldName' => null,
86 'params' => $this->params->toJson(),
87 ], $variables));
88 }
89 
90 protected function setParams(array $fields) ...
91 {
92 foreach ($fields as $field) {
93 if (request()->input('fieldName') === $field && request()->input('fieldValue') !== null) {
94 $this->params->offsetSet($field, request()->input('fieldValue'));
95 }
96 }
97 }
98}

And in our workflow, the Push Notification object looks like this:

We did it! Escaped the loop and notified the user.
We did it! Escaped the loop and notified the user.

Wrapping Up

This was a long one! If you're still with me, thanks for reading.

I think for complex, multi-step Alfred workflows, this is an interesting concept. Let's talk pros and cons:

Pros

  • Flexible. The workflow itself is just an ultra-flexible executor, so you can adjust logic and/or output from the API side without needing the user to update the workflow itself

  • Testable. You're already testing your API (right?), just add tests to your alfred endpoint and make sure it's receiving the results you're expecting. Infinity times easier than testing the workflow itself.

  • Exception messaging. In my opinion, handling validation and exceptions is so much easier this way than looking for all of the edge cases in the workflow itself and displaying them to the user. If validation fails, you can simply re-serve them the prompt again with a message about the failure and ask for the information.

Cons

  • Network lag. Caching could be added, but arguably that defeats the purpose of building the workflow this way. But since every single step requires making an API request, they really need to be zippy, and that's not even taking the user's network speed into account. Especially since there is no "loading screen" during the Run Script object, any lag means that the user is staring at a blank screen between prompts. (If anyone has an idea to solve this elegantly I'm all ears).

  • Too Flexible? I think care should be taken about adjusting the flow of the tool from the API side willy-nilly. Alfred is a productivity tool and users get used to how a certain workflow flows. They want to do something quickly, that's the reason they are using Alfred in the first place. If they see unexpected changes too often they may make a wrong selection or be forced to change their habit, potentially causing frustration.

What do we think? Terrible idea? Genius? Somewhere in between? Let me know! Open to feedback and suggestions.

(Excellent) syntax highlighting provided by Torchlight