@meffecta/agent 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/bin/meffecta-agent.js +26 -8
- package/engine.json +2 -2
- package/lib/commands.js +80 -21
- package/lib/doctor.js +452 -0
- package/lib/flags.js +82 -0
- package/lib/integrations.js +212 -0
- package/package.json +3 -3
- package/scripts/lib/read-json.mjs +68 -0
- package/scripts/setup-scheduler.sh +6 -33
- package/scripts/update-tooling.sh +6 -5
- package/ARCHITECTURE.md +0 -197
- package/IMPLEMENTATION.md +0 -292
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { UserError } from "./config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How to give the agent one more thing it can reach.
|
|
5
|
+
*
|
|
6
|
+
* Every integration is the same three moves in a different order — get a credential, tell
|
|
7
|
+
* the deployment about it, point it at a specific account — and the differences between
|
|
8
|
+
* them are exactly the parts people get stuck on: which console page, which scope, whether
|
|
9
|
+
* the runtime service account needs inviting somewhere. That is what these walk through.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here acts. It prints what to do, in the order to do it, with the commands.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const INTEGRATIONS = {
|
|
15
|
+
gmail: {
|
|
16
|
+
title: "Gmail and Google Calendar",
|
|
17
|
+
gives: "send-email, query-gmail and google-calendar — reading mail, sending it, booking time",
|
|
18
|
+
needs: [
|
|
19
|
+
"An OAuth client in this GCP project: APIs & Services → OAuth consent screen (External,",
|
|
20
|
+
"and PUBLISH it, or tokens expire after 7 days), then Credentials → Create → OAuth",
|
|
21
|
+
"client ID → Desktop app. Enable the Gmail and Calendar APIs while you are there.",
|
|
22
|
+
],
|
|
23
|
+
steps: [
|
|
24
|
+
["meffecta-agent set-secret GMAIL_CLIENT_ID", "from the OAuth client you just made"],
|
|
25
|
+
["meffecta-agent set-secret GMAIL_CLIENT_SECRET", ""],
|
|
26
|
+
[
|
|
27
|
+
"meffecta-agent mint-gmail --client-id <id> --client-secret <secret>",
|
|
28
|
+
"sign in as the account it should act as",
|
|
29
|
+
],
|
|
30
|
+
["meffecta-agent set-secret GMAIL_REFRESH_TOKEN", "paste what mint-gmail printed"],
|
|
31
|
+
["meffecta-agent set-env GMAIL_ADDRESS=agent@yourdomain.com", "which mailbox that token is"],
|
|
32
|
+
],
|
|
33
|
+
more: [
|
|
34
|
+
"A second account follows the GMAIL_<NAME>_* convention — GMAIL_SALES_REFRESH_TOKEN and",
|
|
35
|
+
"GMAIL_SALES_ADDRESS — minted with `mint-gmail --account SALES`, and a job reaches it",
|
|
36
|
+
"with `--account SALES`. Add `--readonly` when minting an account that should never send.",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
outlook: {
|
|
41
|
+
title: "Microsoft 365 mail and calendar",
|
|
42
|
+
gives: "query-outlook — reading a mailbox in someone else's tenant. Read-only by design; it cannot send",
|
|
43
|
+
needs: [
|
|
44
|
+
"An app registration in that tenant's Entra ID: delegated Microsoft Graph permissions",
|
|
45
|
+
"Mail.Read, Calendars.Read and offline_access, with admin consent granted.",
|
|
46
|
+
],
|
|
47
|
+
steps: [
|
|
48
|
+
["meffecta-agent set-secret MSGRAPH_CLIENT_ID", "the application (client) ID"],
|
|
49
|
+
["meffecta-agent set-secret MSGRAPH_CLIENT_SECRET", ""],
|
|
50
|
+
["meffecta-agent set-env MSGRAPH_TENANT_ID=<tenant>", ""],
|
|
51
|
+
["meffecta-agent mint-graph --account WORK", "sign in as the mailbox owner"],
|
|
52
|
+
["meffecta-agent set-secret MSGRAPH_WORK_REFRESH_TOKEN", "paste what mint-graph printed"],
|
|
53
|
+
["meffecta-agent set-env MSGRAPH_WORK_ADDRESS=you@company.com", ""],
|
|
54
|
+
],
|
|
55
|
+
more: [
|
|
56
|
+
"Every mailbox reachable this way belongs to another organisation. Name the account in",
|
|
57
|
+
"the job that may read it, and nowhere else — that is the whole boundary.",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
ga4: {
|
|
62
|
+
title: "Google Analytics 4, Search Console and Google Ads",
|
|
63
|
+
gives: "query-google-marketing — traffic, the queries that bring it, campaign spend",
|
|
64
|
+
needs: [
|
|
65
|
+
"No token at all: these authenticate as the runtime service account, which means",
|
|
66
|
+
"inviting it rather than minting anything. `meffecta-agent status` prints its address,",
|
|
67
|
+
"or look for agent-runtime@<project>.iam.gserviceaccount.com.",
|
|
68
|
+
"",
|
|
69
|
+
"Invite it as a Viewer on each property you want read: the GA4 property (Admin →",
|
|
70
|
+
"Property access management), the Search Console property (Settings → Users and",
|
|
71
|
+
"permissions), and the Google Ads account (Tools → Access and security).",
|
|
72
|
+
],
|
|
73
|
+
steps: [
|
|
74
|
+
["meffecta-agent set-env ACME_GA4_PROPERTY_ID=123456789", "digits only, from GA4 Admin → Property details"],
|
|
75
|
+
[
|
|
76
|
+
"meffecta-agent set-env ACME_SEARCH_CONSOLE_PROPERTY=sc-domain:acme.com",
|
|
77
|
+
"a domain property is sc-domain: plus the bare domain",
|
|
78
|
+
],
|
|
79
|
+
["meffecta-agent set-env ACME_GOOGLE_ADS_CUSTOMER_ID=1234567890", "digits only, no dashes"],
|
|
80
|
+
["meffecta-agent set-secret GOOGLE_ADS_DEVELOPER_TOKEN", "Ads only — from the manager account's API Center"],
|
|
81
|
+
],
|
|
82
|
+
more: [
|
|
83
|
+
"The ACME_ prefix is yours: name it after the project, and have that world's file in",
|
|
84
|
+
"the content repo tell jobs which selector to use. One deployment can carry several.",
|
|
85
|
+
"",
|
|
86
|
+
"These only work on Cloud Run, where the metadata server can vouch for the service",
|
|
87
|
+
"account. Off Cloud Run the skill reports itself unavailable and the job goes on.",
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
grafana: {
|
|
92
|
+
title: "Grafana Cloud logs",
|
|
93
|
+
gives: "query-grafana — the server logs behind an incident, traced by id",
|
|
94
|
+
needs: ["A Grafana Cloud access policy token with the logs:read scope."],
|
|
95
|
+
steps: [
|
|
96
|
+
["meffecta-agent set-env GRAFANA_LOGS_HOST=https://logs-prod-XXX.grafana.net", ""],
|
|
97
|
+
["meffecta-agent set-env GRAFANA_LOGS_USERNAME=123456", "the numeric Loki user id, not an email"],
|
|
98
|
+
["meffecta-agent set-secret GRAFANA_API_KEY", ""],
|
|
99
|
+
],
|
|
100
|
+
more: [
|
|
101
|
+
"Which services exist and what their logs contain is a fact about your product, not",
|
|
102
|
+
"about the credential — put it in the world file your jobs name.",
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
posthog: {
|
|
107
|
+
title: "PostHog product analytics",
|
|
108
|
+
gives: "query-posthog — who did what in the app, funnels, adoption",
|
|
109
|
+
needs: ["A PostHog personal API key with project read access."],
|
|
110
|
+
steps: [
|
|
111
|
+
["meffecta-agent set-secret POSTHOG_API_KEY", ""],
|
|
112
|
+
["meffecta-agent set-env POSTHOG_HOST=https://eu.posthog.com", "or the US host"],
|
|
113
|
+
["meffecta-agent set-env ACME_POSTHOG_PROJECT_ID=12345", "one per project the agent watches"],
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
cloudflare: {
|
|
118
|
+
title: "Cloudflare",
|
|
119
|
+
gives: "cloudflare — DNS, cache purge, Pages deployments, certificate checks",
|
|
120
|
+
needs: ["An API token scoped to the zones it should touch. Read-only unless a job explicitly changes DNS."],
|
|
121
|
+
steps: [
|
|
122
|
+
["meffecta-agent set-secret CLOUDFLARE_API_KEY", "an API *token*, despite the name — sent as a Bearer header"],
|
|
123
|
+
["meffecta-agent set-env CLOUDFLARE_ACCOUNT_ID=<id>", ""],
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
inbox: {
|
|
128
|
+
title: "Triggering a job from incoming mail",
|
|
129
|
+
gives: "a job that wakes on new mail to an address, rather than on a clock",
|
|
130
|
+
needs: [
|
|
131
|
+
"A mailbox the agent can read. Either a Workspace mailbox via domain-wide delegation",
|
|
132
|
+
"(keyless, Cloud Run only), or any Gmail account you have already connected — see",
|
|
133
|
+
"`meffecta-agent connect gmail` first if you have not.",
|
|
134
|
+
],
|
|
135
|
+
steps: [
|
|
136
|
+
[
|
|
137
|
+
"meffecta-agent set-env AGENT_INBOX_MAILBOX=inbox@yourdomain.com",
|
|
138
|
+
"the delegated mailbox — omit for the OAuth path",
|
|
139
|
+
],
|
|
140
|
+
["meffecta-agent set-env AGENT_INBOX_ACCOUNT=SALES", "OAuth path only: which GMAIL_<NAME>_* account to read"],
|
|
141
|
+
["meffecta-agent set-env AGENT_INBOX_POLL_SECONDS=300", "how often; the sweep inherits this cadence"],
|
|
142
|
+
["meffecta-agent setup-scheduler", "re-syncs the sweep to that interval"],
|
|
143
|
+
],
|
|
144
|
+
more: [
|
|
145
|
+
"Then, in the job's frontmatter:",
|
|
146
|
+
"",
|
|
147
|
+
" inbox: crm@yourdomain.com",
|
|
148
|
+
" allowFrom: you@yourdomain.com, @yourdomain.com",
|
|
149
|
+
"",
|
|
150
|
+
"and `deploy` to register it — both fields are read when the service boots.",
|
|
151
|
+
"",
|
|
152
|
+
"allowFrom is required and enforced in code: mail from anyone not listed is dropped",
|
|
153
|
+
"before the run starts, so it never reaches the model. Leave it out and the job",
|
|
154
|
+
"refuses every message — an omission is not read as consent.",
|
|
155
|
+
"",
|
|
156
|
+
" marcus@acme.com one address @acme.com that domain (or *@acme.com)",
|
|
157
|
+
" *@*.acme.com any subdomain * anyone at all",
|
|
158
|
+
"",
|
|
159
|
+
"A subdomain rule does not cover the bare domain; list both if you want both.",
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export function listIntegrations() {
|
|
165
|
+
const width = Math.max(...Object.keys(INTEGRATIONS).map((k) => k.length));
|
|
166
|
+
console.log("Things the agent can be given access to. Each prints what to do, in order:\n");
|
|
167
|
+
for (const [name, entry] of Object.entries(INTEGRATIONS)) {
|
|
168
|
+
console.log(` ${name.padEnd(width)} ${entry.title}`);
|
|
169
|
+
}
|
|
170
|
+
console.log("\n meffecta-agent connect <name>\n");
|
|
171
|
+
console.log("Every one of them is optional, and skipping one costs only the jobs that needed");
|
|
172
|
+
console.log("it: a skill whose settings are unset reports itself unavailable and the run goes on.");
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function connect(args) {
|
|
177
|
+
const name = args[0];
|
|
178
|
+
if (!name) {
|
|
179
|
+
return listIntegrations();
|
|
180
|
+
}
|
|
181
|
+
const entry = INTEGRATIONS[name];
|
|
182
|
+
if (!entry) {
|
|
183
|
+
throw new UserError(`No walkthrough for "${name}".\nAvailable: ${Object.keys(INTEGRATIONS).join(", ")}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`${entry.title}\n`);
|
|
187
|
+
console.log(`Gives the agent: ${entry.gives}\n`);
|
|
188
|
+
if (entry.needs) {
|
|
189
|
+
console.log("Before you start:");
|
|
190
|
+
for (const line of entry.needs) {
|
|
191
|
+
console.log(line ? ` ${line}` : "");
|
|
192
|
+
}
|
|
193
|
+
console.log("");
|
|
194
|
+
}
|
|
195
|
+
console.log("Then, from your content repo:\n");
|
|
196
|
+
for (const [command, why] of entry.steps) {
|
|
197
|
+
console.log(` ${command}`);
|
|
198
|
+
if (why) {
|
|
199
|
+
console.log(` ${why}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
console.log("");
|
|
203
|
+
if (entry.more) {
|
|
204
|
+
for (const line of entry.more) {
|
|
205
|
+
console.log(line ? ` ${line}` : "");
|
|
206
|
+
}
|
|
207
|
+
console.log("");
|
|
208
|
+
}
|
|
209
|
+
console.log("Check it landed: meffecta-agent verify-credentials");
|
|
210
|
+
console.log("Nothing was changed by this command.");
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meffecta/agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Set up and operate a Meffecta Agent deployment — a self-hosted Claude Code job runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
|
14
14
|
"lib",
|
|
15
|
+
"!lib/*.test.mjs",
|
|
15
16
|
"scripts",
|
|
17
|
+
"!scripts/**/*.test.mjs",
|
|
16
18
|
"engine.json",
|
|
17
|
-
"IMPLEMENTATION.md",
|
|
18
|
-
"ARCHITECTURE.md",
|
|
19
19
|
"deployment.env.example",
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pull single values out of the JSON that setup-scheduler.sh reads from gcloud and from the
|
|
3
|
+
// running service. Shell cannot parse JSON and node is already required by the operator
|
|
4
|
+
// tooling (mint-gmail-token.mjs, verify-credentials.mjs), so this replaces what used to be
|
|
5
|
+
// inline python3 — one fewer thing a deployment has to have installed.
|
|
6
|
+
//
|
|
7
|
+
// Reads the document on stdin. Output is plain text, tab-separated where a caller needs
|
|
8
|
+
// more than one value, so the shell can read it with `IFS=$'\t' read`.
|
|
9
|
+
//
|
|
10
|
+
// node read-json.mjs service url|sa|scaling|<ENV_NAME>
|
|
11
|
+
// node read-json.mjs crons # name<TAB>cron, one job per line
|
|
12
|
+
// node read-json.mjs watches-inbox # yes | no
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const [mode, argument] = process.argv.slice(2);
|
|
16
|
+
const document = JSON.parse(readFileSync(0, "utf8"));
|
|
17
|
+
|
|
18
|
+
function service(what) {
|
|
19
|
+
const template = document.spec.template;
|
|
20
|
+
const spec = template.spec;
|
|
21
|
+
const env = new Map((spec.containers[0].env ?? []).map((entry) => [entry.name, entry]));
|
|
22
|
+
const annotations = template.metadata.annotations ?? {};
|
|
23
|
+
|
|
24
|
+
if (what === "url") {
|
|
25
|
+
// An explicitly configured public URL wins over the one Cloud Run reports, so a
|
|
26
|
+
// deployment behind its own domain keeps addressing itself the way it was told to.
|
|
27
|
+
return env.get("AGENT_PUBLIC_URL")?.value || document.status.url;
|
|
28
|
+
}
|
|
29
|
+
if (what === "sa") {
|
|
30
|
+
return spec.serviceAccountName ?? "";
|
|
31
|
+
}
|
|
32
|
+
if (what === "scaling") {
|
|
33
|
+
return [
|
|
34
|
+
annotations["autoscaling.knative.dev/minScale"] ?? "0",
|
|
35
|
+
annotations["run.googleapis.com/cpu-throttling"] ?? "true",
|
|
36
|
+
].join("\t");
|
|
37
|
+
}
|
|
38
|
+
// Any other name is an env var: either a plain value or a Secret Manager reference. The
|
|
39
|
+
// caller is told which, because a secret has to be fetched before it can be used.
|
|
40
|
+
const entry = env.get(what) ?? {};
|
|
41
|
+
if (entry.value) {
|
|
42
|
+
return `value\t${entry.value}`;
|
|
43
|
+
}
|
|
44
|
+
const ref = entry.valueFrom?.secretKeyRef ?? {};
|
|
45
|
+
return `secret\t${ref.name ?? ""}\t${ref.key || "latest"}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const modes = {
|
|
49
|
+
service: () => service(argument),
|
|
50
|
+
// A disabled job keeps its trigger: whether it runs is re-read from the content repo per
|
|
51
|
+
// run, which is what makes `disabled: true` push-effective rather than a redeploy.
|
|
52
|
+
crons: () =>
|
|
53
|
+
document
|
|
54
|
+
.filter((job) => job.cron)
|
|
55
|
+
.map((job) => `${job.name}\t${job.cron}`)
|
|
56
|
+
.join("\n"),
|
|
57
|
+
"watches-inbox": () => (document.some((job) => job.inbox) ? "yes" : "no"),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const run = modes[mode];
|
|
61
|
+
if (!run) {
|
|
62
|
+
process.stderr.write(`read-json.mjs: unknown mode "${mode}"\n`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
const output = run();
|
|
66
|
+
if (output !== "") {
|
|
67
|
+
process.stdout.write(`${output}\n`);
|
|
68
|
+
}
|
|
@@ -53,7 +53,7 @@ while [ $# -gt 0 ]; do
|
|
|
53
53
|
done
|
|
54
54
|
|
|
55
55
|
require_gcloud
|
|
56
|
-
command -v
|
|
56
|
+
command -v node >/dev/null || { echo "node is required (24+)." >&2; exit 1; }
|
|
57
57
|
resolve_deployment
|
|
58
58
|
announce_target "Wiring external triggers for ${SERVICE}"
|
|
59
59
|
|
|
@@ -104,29 +104,10 @@ SERVICE_JSON=$(${GC} run services describe "${SERVICE}" --region="${REGION}" --f
|
|
|
104
104
|
exit 1
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
READ_JSON="$(dirname "${BASH_SOURCE[0]}")/lib/read-json.mjs"
|
|
108
|
+
|
|
107
109
|
svc_field() {
|
|
108
|
-
|
|
109
|
-
import json, sys
|
|
110
|
-
svc = json.loads(sys.stdin.read())
|
|
111
|
-
spec = svc['spec']['template']['spec']
|
|
112
|
-
env = {e['name']: e for e in spec['containers'][0].get('env', [])}
|
|
113
|
-
what = sys.argv[1]
|
|
114
|
-
if what == 'url':
|
|
115
|
-
print((env.get('AGENT_PUBLIC_URL', {}).get('value')) or svc['status']['url'])
|
|
116
|
-
elif what == 'sa':
|
|
117
|
-
print(spec.get('serviceAccountName') or '')
|
|
118
|
-
elif what == 'scaling':
|
|
119
|
-
ann = svc['spec']['template']['metadata'].get('annotations', {})
|
|
120
|
-
print('{}\t{}'.format(ann.get('autoscaling.knative.dev/minScale', '0'),
|
|
121
|
-
ann.get('run.googleapis.com/cpu-throttling', 'true')))
|
|
122
|
-
else:
|
|
123
|
-
e = env.get(what, {})
|
|
124
|
-
if e.get('value'):
|
|
125
|
-
print('value\t' + e['value'])
|
|
126
|
-
else:
|
|
127
|
-
ref = e.get('valueFrom', {}).get('secretKeyRef', {})
|
|
128
|
-
print('secret\t{}\t{}'.format(ref.get('name', ''), ref.get('key') or 'latest'))
|
|
129
|
-
" "$1"
|
|
110
|
+
node "${READ_JSON}" service "$1"
|
|
130
111
|
}
|
|
131
112
|
|
|
132
113
|
AGENT_URL=$(printf '%s' "${SERVICE_JSON}" | svc_field url)
|
|
@@ -209,16 +190,8 @@ EOF
|
|
|
209
190
|
# name<TAB>cron for every job that declares one. A disabled job keeps its trigger: whether
|
|
210
191
|
# it runs is re-read from the content repo per run, which is what makes `disabled: true`
|
|
211
192
|
# push-effective rather than a redeploy.
|
|
212
|
-
CRON_JOBS=$(printf '%s' "${JOBS_JSON}" |
|
|
213
|
-
|
|
214
|
-
for job in json.load(sys.stdin):
|
|
215
|
-
if job.get('cron'):
|
|
216
|
-
print('{}\t{}'.format(job['name'], job['cron']))
|
|
217
|
-
")
|
|
218
|
-
WATCHES_INBOX=$(printf '%s' "${JOBS_JSON}" | python3 -c "
|
|
219
|
-
import json, sys
|
|
220
|
-
print('yes' if any(j.get('inbox') for j in json.load(sys.stdin)) else 'no')
|
|
221
|
-
")
|
|
193
|
+
CRON_JOBS=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" crons)
|
|
194
|
+
WATCHES_INBOX=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" watches-inbox)
|
|
222
195
|
|
|
223
196
|
HEADERS="Content-Type=application/json,Authorization=Bearer ${WEBHOOK_SECRET}"
|
|
224
197
|
|
|
@@ -3,10 +3,11 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
# Refresh this deployment's operator tooling from a published engine image.
|
|
5
5
|
#
|
|
6
|
-
# The engine's source repo is private; the image is not.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# from
|
|
6
|
+
# The engine's source repo is private; the image is not. The operator tooling — these
|
|
7
|
+
# scripts and the config template — ships inside ghcr.io/meffecta/agent, so a deployment run
|
|
8
|
+
# by someone outside Meffecta gets its tooling from the same public artifact it gets its
|
|
9
|
+
# engine from, and never needs the source. What a deployment is documented by is the CLI's
|
|
10
|
+
# own help: `meffecta-agent help` and `meffecta-agent steps`.
|
|
10
11
|
#
|
|
11
12
|
# The tooling belongs in your content repo, committed next to jobs/ and deployment.env.
|
|
12
13
|
# That is what makes a fresh clone of that repo enough to operate the deployment, and it
|
|
@@ -71,7 +72,7 @@ docker pull --quiet --platform linux/amd64 "${IMAGE}" >/dev/null || {
|
|
|
71
72
|
CID="$(docker create --platform linux/amd64 "${IMAGE}")"
|
|
72
73
|
# `docker cp` copies out as the host user, so nothing depends on the uid inside the image.
|
|
73
74
|
docker cp "${CID}:/app/scripts" "${TMP}/scripts" >/dev/null
|
|
74
|
-
for doc in
|
|
75
|
+
for doc in deployment.env.example; do
|
|
75
76
|
docker cp "${CID}:/app/${doc}" "${TMP}/scripts/${doc}" >/dev/null 2>&1 || true
|
|
76
77
|
done
|
|
77
78
|
docker rm "${CID}" >/dev/null
|
package/ARCHITECTURE.md
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
# How the agent is put together
|
|
2
|
-
|
|
3
|
-
Orientation for someone about to run a deployment. The set-up steps themselves are in
|
|
4
|
-
[IMPLEMENTATION.md](IMPLEMENTATION.md); this is what those steps are building and why.
|
|
5
|
-
|
|
6
|
-
## The shape
|
|
7
|
-
|
|
8
|
-
```
|
|
9
|
-
this engine repo ──CI──▶ ghcr.io/meffecta/agent (the image; never company-specific)
|
|
10
|
-
│
|
|
11
|
-
│ scripts/deploy.sh
|
|
12
|
-
▼
|
|
13
|
-
your content repo ──cloned every run──▶ Cloud Run service (your project, your creds)
|
|
14
|
-
jobs/ worlds/ SYSTEM.md ↑
|
|
15
|
-
└─ credentials in Secret Manager
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
The engine is a job runner: it wakes on a schedule (or a webhook, or new mail), clones the
|
|
19
|
-
content repo, and hands one job's prompt to the `claude` CLI with a working directory, a
|
|
20
|
-
system prompt, a set of skills and a memory directory. What the agent *does* is entirely
|
|
21
|
-
in the content repo and the credentials; the engine knows nothing about any company.
|
|
22
|
-
|
|
23
|
-
## Two rules that explain most of the design
|
|
24
|
-
|
|
25
|
-
**The engine is generic; the content repo is yours.** Every run re-clones the content repo,
|
|
26
|
-
so a pushed prompt is live on the next run — no build, no deploy. Only moving to a new
|
|
27
|
-
*engine version* is a deploy. This is also why the engine repo's CI publishes an image and
|
|
28
|
-
stops: when a deployment takes a new version is that deployment's call.
|
|
29
|
-
|
|
30
|
-
**The agent's power is exactly its credentials.** There is no other lever. Give it a GCP
|
|
31
|
-
project of its own, and add one credential at a time — every skill degrades gracefully
|
|
32
|
-
when its variables are unset, so a half-configured agent is a working agent with fewer
|
|
33
|
-
abilities, not a broken one. Reviewing what an agent can do means reviewing what its
|
|
34
|
-
service account is invited to and what tokens sit in its Secret Manager.
|
|
35
|
-
|
|
36
|
-
## What needs what
|
|
37
|
-
|
|
38
|
-
| Change | Effect |
|
|
39
|
-
| --- | --- |
|
|
40
|
-
| Job prompts, run settings, `SYSTEM.md`, worlds, content-repo skills | **Push** — live on the next run |
|
|
41
|
-
| Adding, renaming, or retriggering a job (`cron`/`webhook`/`inbox` frontmatter) | **Restart** — triggers register from a boot-time clone, plus `scripts/setup-scheduler.sh` on Cloud Run |
|
|
42
|
-
| A new engine version | `scripts/deploy.sh` (which re-syncs the triggers too) |
|
|
43
|
-
|
|
44
|
-
Renaming a job also moves its identity: memory lives at `<MEMORY_DIR>/jobs/<name>/`, so
|
|
45
|
-
copy that directory across or the job wakes up amnesiac, and pending spawns for the old
|
|
46
|
-
name die with it.
|
|
47
|
-
|
|
48
|
-
## Operating a deployment
|
|
49
|
-
|
|
50
|
-
Operating a deployment is one command-line tool, **`npx @meffecta/agent`**, published to
|
|
51
|
-
npm alongside the engine image it deploys. Installing it is all the access an operator
|
|
52
|
-
needs; the engine's source is not involved. Each release of the tool records the engine
|
|
53
|
-
build it was published with, so upgrading the tool is how you move to a new engine —
|
|
54
|
-
deliberately, rather than by surprise.
|
|
55
|
-
|
|
56
|
-
It reads this deployment's identity from a `deployment.env` in your content repo, committed
|
|
57
|
-
beside `jobs/`. Which GCP project a set of jobs runs as is a fact about that deployment,
|
|
58
|
-
and none of it is secret.
|
|
59
|
-
|
|
60
|
-
That makes the working shape: stand in the content repo and run the tool.
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
cd /path/to/acme-agent-home
|
|
64
|
-
npx @meffecta/agent status
|
|
65
|
-
npx @meffecta/agent deploy
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Nothing is read from exported shell variables, so a command cannot silently act on the
|
|
69
|
-
wrong deployment; each script prints the project and service it is about to touch, and
|
|
70
|
-
refuses to run when it cannot find them (`--config <file>` overrides the lookup).
|
|
71
|
-
|
|
72
|
-
`deployment.env` also holds the **runtime shape** — `SCALING`, `CPU`, `MEMORY`,
|
|
73
|
-
`MAX_INSTANCES`, `REQUEST_TIMEOUT` — which `deploy.sh` re-asserts on every rollout. So the
|
|
74
|
-
file, not the console, is the truth about how the service runs, and `SCALING` in particular
|
|
75
|
-
has to stay honest: it and the triggers are one decision made in two places.
|
|
76
|
-
|
|
77
|
-
Every command is one of the scripts below with a name in front of it — the CLI is the
|
|
78
|
-
interface, the scripts are the implementation, and they are published together so the pair
|
|
79
|
-
cannot drift. A deployment without npm can extract the scripts from the public image
|
|
80
|
-
instead and run them directly.
|
|
81
|
-
|
|
82
|
-
| Command / script | Does |
|
|
83
|
-
| --- | --- |
|
|
84
|
-
| `setup-infrastructure.sh` | one-time GCP provisioning; idempotent |
|
|
85
|
-
| `setup-scheduler.sh` | the deployment's external triggers — Cloud Scheduler + Tasks; idempotent |
|
|
86
|
-
| `set-artifact-cleanup.sh` | expire mirrored images in the deployment's registry |
|
|
87
|
-
| `update-tooling.sh` | refresh these scripts from a published engine image |
|
|
88
|
-
| `set-secret.sh` | store a value in Secret Manager and bind it; re-run to rotate |
|
|
89
|
-
| `set-env.sh` | non-secret service settings, several per revision |
|
|
90
|
-
| `deploy.sh` | roll a published engine image out, and assert the runtime shape |
|
|
91
|
-
|
|
92
|
-
For a question rather than a change, the service serves **`/ask`**: a prompt box behind the
|
|
93
|
-
same secret, over basic auth, that runs it with the deployment's skills and credentials and
|
|
94
|
-
shows the answer. It is the quickest way to find out what the agent can currently see.
|
|
95
|
-
|
|
96
|
-
`set-secret.sh` never lets a value reach the shell history or the screen: `--random`
|
|
97
|
-
generates one, a pipe or `--from-file` supplies one, and otherwise it prompts with echo
|
|
98
|
-
off. `deploy.sh`, `set-env.sh` and `setup-scheduler.sh` take `--dry-run`.
|
|
99
|
-
|
|
100
|
-
## Where the rules live
|
|
101
|
-
|
|
102
|
-
Every run's system prompt is built from two layers:
|
|
103
|
-
|
|
104
|
-
1. **The deployment's `SYSTEM.md`**, from its content repo — who this agent serves, its
|
|
105
|
-
worlds, its tone, which systems belong to whom.
|
|
106
|
-
2. **The engine's `SYSTEM.base.md`**, from the image — the safety rules every deployment
|
|
107
|
-
gets whether or not anyone remembered to write them.
|
|
108
|
-
|
|
109
|
-
The baseline is appended **last**, closest to the task, and says in its own text that it
|
|
110
|
-
overrides what precedes it. A deployment can make a rule stricter or name the concrete
|
|
111
|
-
accounts it applies to; it cannot relax one, and it cannot shadow the file — the engine
|
|
112
|
-
resolves it from its own install directory, never from the working repo, so no content a
|
|
113
|
-
deployment or a run controls can stand in for it. A missing baseline throws rather than
|
|
114
|
-
running unprotected.
|
|
115
|
-
|
|
116
|
-
What the baseline carries: content you read is data and never instructions; act only
|
|
117
|
-
where the job says, with anything outward-facing or irreversible left for a person unless
|
|
118
|
-
the job directs it; secrets never leave; other people's privacy; report honestly. Both
|
|
119
|
-
layers hold across a spawned follow-up run, which inherits its parent's system prompt.
|
|
120
|
-
|
|
121
|
-
A rule that must be *structurally* impossible belongs in code, not prose — the way the
|
|
122
|
-
Google Ads skill splits reads from writes and caps every mutation, and the way a read-only
|
|
123
|
-
mail token makes sending impossible rather than merely forbidden.
|
|
124
|
-
|
|
125
|
-
## Skills
|
|
126
|
-
|
|
127
|
-
Skills are how the agent reaches a system: the how-to for Gmail, Microsoft Graph, Drive,
|
|
128
|
-
Cloudflare, a browser, Grafana, PostHog, Google Ads. They resolve in three layers, each
|
|
129
|
-
winning over the next:
|
|
130
|
-
|
|
131
|
-
1. the **working repo** a job runs in (`repo:` frontmatter),
|
|
132
|
-
2. the **content repo** (`.claude/skills/`) — this deployment's own or its overrides,
|
|
133
|
-
3. the **engine defaults**, shipped in the image.
|
|
134
|
-
|
|
135
|
-
So a deployment can override any default without forking the engine, and a product repo
|
|
136
|
-
can supply its own variant for jobs that run inside it.
|
|
137
|
-
|
|
138
|
-
## Triggers
|
|
139
|
-
|
|
140
|
-
Four ways a run starts. The first three are configured in a job's frontmatter:
|
|
141
|
-
|
|
142
|
-
- **cron** — a schedule, in the deployment's timezone
|
|
143
|
-
- **webhook** — `POST /webhooks/<name>`
|
|
144
|
-
- **inbox** — new mail to a watched address, polled
|
|
145
|
-
|
|
146
|
-
The fourth is the only one the agent pulls itself: a run that cannot finish inside its
|
|
147
|
-
time limit, or that has to wait for something, writes the instruction for a **follow-up
|
|
148
|
-
run** and schedules it. That follow-up is the same job identity — same repo, system
|
|
149
|
-
prompt, settings and memory — with a fresh time limit. The token that authorizes it stands
|
|
150
|
-
for that run's identity and dies with the run, so a run can only ever schedule more of its
|
|
151
|
-
own job's work, whatever its prompt says.
|
|
152
|
-
|
|
153
|
-
### What actually wakes the service
|
|
154
|
-
|
|
155
|
-
On Cloud Run the service **scales to zero and is billed only while a request is open**,
|
|
156
|
-
which is why an agent that is busy half an hour a day does not cost a machine's worth a
|
|
157
|
-
month. The price of that is a service with no CPU of its own: it cannot keep a clock, and
|
|
158
|
-
it cannot finish work in the background of a response. So all four triggers are delivered
|
|
159
|
-
to it as HTTP requests, and the handler holds each request open until the run settles.
|
|
160
|
-
|
|
161
|
-
`scripts/setup-scheduler.sh` creates the senders: **Cloud Scheduler** for the crons and for
|
|
162
|
-
an hourly (or poll-interval) housekeeping sweep, **Cloud Tasks** for everything that has to
|
|
163
|
-
be acknowledged immediately — webhooks, manual runs, follow-ups due later. Both cap one
|
|
164
|
-
attempt at 30 minutes, so a run that lasts longer spans several: the handler answers 503,
|
|
165
|
-
and the retry re-attaches to the same run instead of starting another. Nothing about
|
|
166
|
-
durability rests on this — the queue journal and the spawn spool on the memory bucket stay
|
|
167
|
-
the record, and the sweep re-drives anything a delivery lost.
|
|
168
|
-
|
|
169
|
-
The alternative shape is a machine that is always awake — a Mac mini, or a service pinned
|
|
170
|
-
to `min-instances=1`. There the process keeps its own timers and needs none of the above;
|
|
171
|
-
leaving `AGENT_TASKS_QUEUE` unset is what selects it. Do not do half of each: a
|
|
172
|
-
scaled-to-zero service without Scheduler jobs looks healthy and runs nothing, and an
|
|
173
|
-
always-on service with them fires every cron twice.
|
|
174
|
-
|
|
175
|
-
## Memory and audit
|
|
176
|
-
|
|
177
|
-
Each job has a memory directory it reads at the start of a run and updates at the end —
|
|
178
|
-
working state, not bulk content. It lives on a mounted bucket, so it survives restarts and
|
|
179
|
-
deploys.
|
|
180
|
-
|
|
181
|
-
Separately, every run writes to an **audit bucket** that is never mounted and never
|
|
182
|
-
readable by the agent: the prompt, the trigger, the result, and the full transcript of
|
|
183
|
-
every tool call. That is where you look when a run surprises you, and it is deliberately
|
|
184
|
-
out of the agent's own reach.
|
|
185
|
-
|
|
186
|
-
## Where it runs
|
|
187
|
-
|
|
188
|
-
Cloud Run is the default and the only place where service-account authentication works for
|
|
189
|
-
the metadata-server surfaces (domain-wide delegation, GA4, Search Console, Ads, deployment
|
|
190
|
-
listing). The same image runs anywhere docker does, and the service also runs natively on
|
|
191
|
-
a Mac or Linux box with Node 24+, `MEMORY_DIR` pointed at a real directory and
|
|
192
|
-
`NODE_ENV=production` to enable crons — keep it alive with launchd or systemd. Off Cloud
|
|
193
|
-
Run, the metadata-server surfaces degrade to their fallbacks.
|
|
194
|
-
|
|
195
|
-
**Run it in exactly one place at a time.** Two instances mean every cron fires twice and
|
|
196
|
-
both send email — and on Cloud Run, scaling to zero is what makes the second copy tempting,
|
|
197
|
-
so check that the first one is actually stopped rather than merely idle.
|