eleven / ocho Public

web+ios: an email typed as your name gets a second look (#1473)

Answering "what should everyone here call you?" with an email address made
it your name, silently — the name the whole space sees.

input.confirm, a new rule in the flow language: a matching answer jumps to
a step the author writes, and nothing is recorded and no effect runs until
the person picks "Use it anyway" or "Type a different name". Both name
steps (setup + welcome) use it, in both engines, with the native bundles
regenerated. An unknown rule never fires.

Also: nativeEnv never set bridge.canEnrol, so testQuickFormTrailingChip
has been red on main since #1377 — the welcome access step skipped its
chips as if self-hosted.


Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 38a736c5de644be49e604653a05aeae75f63fcf1 parent c040f49
9 files changed, +313 −5
  • changelog/2026-08-29-name-email-confirm.md +4 −0
  • ios/LChatCore/Sources/LChatCore/FlowEngine.swift +34 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +39 −1
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +39 −1
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +46 −0
  • test/flow.test.mjs +70 −1
  • web/static/flow.js +25 −0
  • web/static/setup.flow.js +32 −1
  • web/static/welcome.flow.js +24 −1
diff --git a/changelog/2026-08-29-name-email-confirm.md b/changelog/2026-08-29-name-email-confirm.md
new file mode 100644
index 0000000..dcaa7e4
--- /dev/null
+++ b/changelog/2026-08-29-name-email-confirm.md
@@ -0,0 +1,4 @@
+🤖 Type an email address when Eleven asks what you'd like to be called, and it
+now checks before taking you at your word — "that's an email address, is that
+what you'd like to be called here?" — with a chip to keep it or type something
+else. It's the name everyone in the space sees, so it's worth a second look.
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
index abb0fc5..2c26bfd 100644
--- a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
+++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
@@ -135,6 +135,33 @@ public final class FlowEnv {
}
}
+// MARK: - input.confirm: an answer that reads as something else
+
+// A name step accepts anything — which is right (people are called all sorts)
+// but means an email address typed into "what should everyone here call you?"
+// sails through and becomes the name the whole space sees. `input.confirm`
+// asks first: nothing is recorded and no effect runs until the person says
+// yes, and the flow author writes the confirming step like any other.
+// Whole-string only, so "Ada @ the shop" stays a name.
+// Twin: looksLikeEmail in web/static/flow.js.
+func looksLikeEmail(_ raw: String) -> Bool {
+ let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let at = s.firstIndex(of: "@"), at != s.startIndex else { return false }
+ let local = s[s.startIndex..<at]
+ let domain = s[s.index(after: at)...]
+ guard !domain.isEmpty, !local.contains(" "), !domain.contains(" "), !domain.contains("@") else { return false }
+ guard let dot = domain.lastIndex(of: "."), dot != domain.startIndex else { return false }
+ return domain[domain.index(after: dot)...].count >= 2
+}
+
+// confirmNeeded: does this answer trip the step's confirm rule? An unknown
+// `when` never fires — a flow document from a newer build must degrade to
+// today's behaviour, not to a step nobody can leave.
+func confirmNeeded(_ confirm: FlowJSON, answer: String) -> Bool {
+ guard let target = confirm["goto"].stringValue, !target.isEmpty else { return false }
+ return confirm["when"].stringValue == "email" && looksLikeEmail(answer)
+}
+
// MARK: - the name-step answer cleaner (input.clean == "name")
// People answer a conversational "what should folks here call you?"
@@ -506,6 +533,13 @@ public final class FlowEngine {
await self.enter(stepId)
return
}
+ // Confirm before accepting (input.confirm): the answer
+ // stays in vars.answer for the confirming step's chips.
+ if confirmNeeded(input["confirm"], answer: text),
+ let target = input["confirm"]["goto"].stringValue {
+ await self.enter(target)
+ return
+ }
if !step["effect"].isNull {
await self.runEffect(stepId: stepId, effect: step["effect"])
}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 190ae24..08b8710 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -48,6 +48,7 @@
"skipIf": [
"done"
],
+ "skipTo": "group",
"bubbles": [
{
"text": "First — what should everyone here call you? Reply with your name.",
@@ -69,7 +70,11 @@
"kind": "text",
"required": true,
"maxLen": 32,
- "clean": "name"
+ "clean": "name",
+ "confirm": {
+ "when": "email",
+ "goto": "name-email"
+ }
},
"effect": {
"verb": "setMyName",
@@ -88,6 +93,39 @@
}
}
},
+ {
+ "id": "name-email",
+ "bubbles": [
+ {
+ "text": "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.",
+ "key": "q-name-email"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Use it anyway",
+ "spent": "Kept",
+ "effect": {
+ "verb": "setMyName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "record": {
+ "step": "name",
+ "label": "noted: you're {vars.answer}",
+ "value": "$answer"
+ },
+ "ack": "Righto — {vars.answer} it is. 🤝",
+ "goto": "group"
+ },
+ {
+ "label": "Type a different name",
+ "spent": "Changed",
+ "goto": "name"
+ }
+ ]
+ },
{
"id": "group",
"skipIf": [
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index 4b37670..32217d7 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -176,6 +176,7 @@
"done",
"bridge.myName"
],
+ "skipTo": "photo",
"bubbles": [
{
"text": "{ack?}First things first — what should folks here call you? Reply with your name.",
@@ -209,7 +210,11 @@
"kind": "text",
"required": true,
"maxLen": 32,
- "clean": "name"
+ "clean": "name",
+ "confirm": {
+ "when": "email",
+ "goto": "name-email"
+ }
},
"effect": {
"verb": "setMyName",
@@ -228,6 +233,39 @@
}
}
},
+ {
+ "id": "name-email",
+ "bubbles": [
+ {
+ "text": "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.",
+ "key": "q-name-email"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Use it anyway",
+ "spent": "Kept",
+ "effect": {
+ "verb": "setMyName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "record": {
+ "step": "name",
+ "label": "noted: you're {vars.answer}",
+ "value": "$answer"
+ },
+ "ack": "Righto — {vars.answer} it is. 🤝",
+ "goto": "photo"
+ },
+ {
+ "label": "Type a different name",
+ "spent": "Changed",
+ "goto": "name"
+ }
+ ]
+ },
{
"id": "photo",
"skipIf": [
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
index 437d955..4c51cc7 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
@@ -85,6 +85,12 @@ final class FlowEngineTests: XCTestCase {
env.live["bridge.myName"] = { .string(name) }
env.live["bridge.enrolled"] = { .bool(false) }
env.live["bridge.fromHome"] = { .bool(false) }
+ // Matches the JS harness default. Without it every flow read
+ // "!bridge.canEnrol" as TRUE — a self-hosted space with no sign-in to
+ // offer — so the welcome access step recorded "your access lives in
+ // this browser" and skipped past its chips, quietly rotting
+ // testQuickFormTrailingChip (red on main since the #1377 gate landed).
+ env.live["bridge.canEnrol"] = { .bool(true) }
env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
return env
}
@@ -158,6 +164,46 @@ final class FlowEngineTests: XCTestCase {
XCTAssertTrue(allChips.contains("Open admin"))
}
+ // MARK: input.confirm — an email answered as a name
+
+ // Twin of "setup: an email answered as a name is confirmed, not swallowed"
+ // in test/flow.test.mjs. Both engines must hold the answer back until the
+ // person confirms it: nothing recorded, no setMyName, no move on.
+ func testEmailAnsweredAsNameIsConfirmed() async throws {
+ let flow = try moduleFlow("setup")
+ let io = Harness()
+ io.verbResults["setMyName"] = .string("ok")
+ io.verbResults["setGroupName"] = .string("ok")
+ let env = nativeEnv(name: "", space: "") // no name yet: the question is asked
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ await io.answer("paul@rslw.com")
+ XCTAssertTrue(io.transcript.last?.text.contains("That's an email address") ?? false,
+ "the confirm step must come up")
+ XCTAssertFalse(io.verbLog.contains { $0.0 == "setMyName" }, "nothing set yet")
+ XCTAssertFalse(io.records.contains { $0.step == "name" }, "nothing recorded yet")
+
+ await io.tap("Use it anyway")
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" })
+ XCTAssertEqual(io.records.filter { $0.step == "name" }.count, 1,
+ "the confirm records the NAME step, once")
+ XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" },
+ "carried on to the space name")
+ }
+
+ // Twin of the looksLikeEmail cases in test/flow.test.mjs.
+ func testLooksLikeEmail() {
+ for yes in ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"] {
+ XCTAssertTrue(looksLikeEmail(yes), yes)
+ }
+ for no in ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", "two@parts@here.com"] {
+ XCTAssertFalse(looksLikeEmail(no), no)
+ }
+ }
+
// MARK: the native joiner welcome run
// Twin of the cleanNameAnswer cases in test/flow.test.mjs — both engines
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index d65924d..7ec25af 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -5,7 +5,7 @@
// fixture discipline the Swift interpreter will inherit.
import assert from "node:assert/strict";
import { test } from "node:test";
-import { runFlow, renderText, evalCond, cleanNameAnswer } from "../web/static/flow.js";
+import { runFlow, renderText, evalCond, cleanNameAnswer, looksLikeEmail, confirmNeeded } from "../web/static/flow.js";
import SETUP_FLOW from "../web/static/setup.flow.js";
import WELCOME_FLOW from "../web/static/welcome.flow.js";
@@ -117,6 +117,61 @@ await test("cleanNameAnswer strips conversational replies down to the name", ()
assert.equal(cleanNameAnswer("Im Dermot"), "Dermot"); // apostrophe skipped
});
+await test("looksLikeEmail: whole-string only — a name with an @ in it is a name", () => {
+ for (const yes of ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"]) {
+ assert.equal(looksLikeEmail(yes), true, yes);
+ }
+ for (const no of ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", null, "two@parts@here.com"]) {
+ assert.equal(looksLikeEmail(no), false, String(no));
+ }
+});
+
+await test("confirmNeeded: only a known rule fires, and only with a destination", () => {
+ assert.equal(confirmNeeded({ when: "email", goto: "x" }, "a@b.com"), true);
+ assert.equal(confirmNeeded({ when: "email", goto: "x" }, "Ada"), false);
+ assert.equal(confirmNeeded({ when: "email" }, "a@b.com"), false, "no goto, no jump");
+ // Forward compatibility: a rule this build doesn't know must behave as if
+ // absent rather than park the conversation on an unreachable step.
+ assert.equal(confirmNeeded({ when: "phone", goto: "x" }, "a@b.com"), false);
+ assert.equal(confirmNeeded(null, "a@b.com"), false);
+});
+
+// An email typed where a name was asked used to become the name the whole
+// space sees, silently (Paul, 2026-08-29). Now it is confirmed first — and
+// until it is, nothing is recorded and setMyName never runs.
+await test("setup: an email answered as a name is confirmed, not swallowed", async () => {
+ const h = harness({ verbs: { setMyName: "ok", setGroupName: "ok" } });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ await h.answer("paul@rslw.com");
+
+ assert.match(h.lastText(), /That's an email address/);
+ assert.equal(h.verbLog.some((v) => v[0] === "setMyName"), false, "nothing set yet");
+ assert.equal(h.records.some((r) => r.step === "name"), false, "nothing recorded yet");
+
+ // Changing your mind re-asks the name, still with nothing recorded.
+ await h.tap("Type a different name");
+ assert.equal(h.records.some((r) => r.step === "name"), false);
+ await h.answer("Paul");
+ assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "Paul"]);
+ assert.equal(h.records.find((r) => r.step === "name").value, "Paul");
+ assert.ok(h.transcript.find((t) => t.extra.key === "q-group"), "carried on to the space name");
+});
+
+await test("setup: 'Use it anyway' keeps the email as the name, recorded once", async () => {
+ const h = harness({ verbs: { setMyName: "ok", setGroupName: "ok" } });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ await h.answer("paul@rslw.com");
+ await h.tap("Use it anyway");
+
+ assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "paul@rslw.com"]);
+ const rec = h.records.filter((r) => r.step === "name");
+ assert.equal(rec.length, 1, "the confirm records the NAME step, once");
+ assert.equal(rec[0].value, "paul@rslw.com");
+ assert.ok(h.transcript.find((t) => t.extra.key === "q-group"), "carried on to the space name");
+});
+
await test("cleanNameAnswer leaves plain names (and lookalikes) alone", () => {
assert.equal(cleanNameAnswer("Dermot"), "Dermot");
assert.equal(cleanNameAnswer(" Dermot "), "Dermot");
@@ -615,6 +670,20 @@ const welcomeEnv = (over = {}) => ({
...over,
});
+await test("welcome: the joiner's name step confirms an email the same way", async () => {
+ // Past the access step (already signed in) so the name question is up.
+ const h = harness({ verbs: { setMyName: "ok" }, enrolled: true,
+ done: { access: true }, envExtra: welcomeEnv() });
+ h.env.accessBackedUp = () => true;
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+ await h.answer("paul@rslw.com");
+ assert.match(h.lastText(), /That's an email address/);
+ await h.tap("Use it anyway");
+ assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "paul@rslw.com"]);
+ assert.equal(h.records.filter((r) => r.step === "name").length, 1);
+});
+
// The quick-form fast path: both conversations' FIRST interactive step offers
// a right-aligned "Fill in a form instead" chip whose tap opens the host form
// (the openQuickForm verb) WITHOUT consuming the step — the conversation stays
diff --git a/web/static/flow.js b/web/static/flow.js
index af2fe90..e7098e9 100644
--- a/web/static/flow.js
+++ b/web/static/flow.js
@@ -17,6 +17,26 @@
// delay(ms, fn) — a cancellable timer (probes); tests fire it
// }
+// ---- input.confirm: an answer that reads as something else ---------------
+// A name step accepts anything — which is right (people are called all sorts)
+// but means an email address typed into "what should everyone here call you?"
+// sails through and becomes the name the whole space sees. `input.confirm`
+// asks first: nothing is recorded and no effect runs until the person says
+// yes, and the flow author writes the confirming step like any other.
+// Whole-string only, so "Ada @ the shop" stays a name.
+// Twin: looksLikeEmail in FlowEngine.swift (native runs the same flows).
+export function looksLikeEmail(s) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test((s || "").trim());
+}
+
+// confirmNeeded: does this answer trip the step's confirm rule? An unknown
+// `when` never fires — a flow document from a newer build must degrade to
+// today's behaviour, not to a step nobody can leave.
+export function confirmNeeded(confirm, answer) {
+ if (!confirm || !confirm.goto) return false;
+ return confirm.when === "email" && looksLikeEmail(answer);
+}
+
// ---- the name-step answer cleaner (input.clean === "name") ---------------
// People answer a conversational "what should folks here call you?"
// conversationally — "I'm Dermot", "hi, my name is Ana." — and the raw reply
@@ -263,6 +283,11 @@ export function runFlow(flow, env, io) {
// A required answer left empty re-asks the whole step (say-dedupe by
// key means no duplicate bubbles — the input just re-arms).
if (step.input.required && !env.vars.answer) return enter(step.id);
+ // Confirm before accepting (input.confirm): the answer stays in
+ // vars.answer for the confirming step's chips to use or discard.
+ if (confirmNeeded(step.input.confirm, env.vars.answer)) {
+ return enter(step.input.confirm.goto);
+ }
if (step.effect) await runEffect(stepCtx, step.effect);
})
);
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js
index 2e73c20..a06c4b4 100644
--- a/web/static/setup.flow.js
+++ b/web/static/setup.flow.js
@@ -46,6 +46,11 @@ export default {
},
],
skipIf: ["done"],
+ // Explicit, because the confirm step (name-email) now sits between this
+ // one and "group": a skip falls through in ARRAY order, and landing a
+ // resumed client on a confirmation for an answer it never gave would
+ // park the whole conversation there.
+ skipTo: "group",
bubbles: [
{ text: "First — what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" },
],
@@ -60,7 +65,10 @@ export default {
effect: { verb: "openQuickForm" },
},
],
- input: { kind: "text", required: true, maxLen: 32, clean: "name" },
+ // An email address typed here would become the name the whole space
+ // sees, silently. Confirm it first (flow.js input.confirm) — everything
+ // else is accepted as typed, because people are called all sorts.
+ input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } },
effect: { verb: "setMyName", args: ["$answer"] },
on: {
ok: {
@@ -70,6 +78,29 @@ export default {
},
},
},
+ {
+ // The confirm step for an email answered as a name. Nothing has been
+ // recorded yet, so both chips are live choices: keeping it runs the
+ // effect + record the name step would have, and changing it re-asks.
+ id: "name-email",
+ bubbles: [
+ {
+ text: "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.",
+ key: "q-name-email",
+ },
+ ],
+ chips: [
+ {
+ label: "Use it anyway",
+ spent: "Kept",
+ effect: { verb: "setMyName", args: ["$answer"] },
+ record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" },
+ ack: "Righto — {vars.answer} it is. 🤝",
+ goto: "group",
+ },
+ { label: "Type a different name", spent: "Changed", goto: "name" },
+ ],
+ },
{
id: "group",
skipIf: ["done"],
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js
index db53ec5..7037c17 100644
--- a/web/static/welcome.flow.js
+++ b/web/static/welcome.flow.js
@@ -140,6 +140,7 @@ export default {
{
id: "name",
skipIf: ["done", "bridge.myName"],
+ skipTo: "photo", // past name-email, the confirm step (see setup.flow.js)
bubbles: [
{
text: "{ack?}First things first — what should folks here call you? Reply with your name.",
@@ -159,7 +160,9 @@ export default {
goto: "photo",
},
],
- input: { kind: "text", required: true, maxLen: 32, clean: "name" },
+ // Same guard as setup's name step: an email typed here would become the
+ // name everyone in the space sees (flow.js input.confirm).
+ input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } },
effect: { verb: "setMyName", args: ["$answer"] },
on: {
ok: {
@@ -169,6 +172,26 @@ export default {
},
},
},
+ {
+ id: "name-email",
+ bubbles: [
+ {
+ text: "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.",
+ key: "q-name-email",
+ },
+ ],
+ chips: [
+ {
+ label: "Use it anyway",
+ spent: "Kept",
+ effect: { verb: "setMyName", args: ["$answer"] },
+ record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" },
+ ack: "Righto — {vars.answer} it is. 🤝",
+ goto: "photo",
+ },
+ { label: "Type a different name", spent: "Changed", goto: "name" },
+ ],
+ },
{
id: "photo",
// Native v1: no attachment flow in the conversation composer — the