i was not able to prove to project was deployed on google cloud ON THE DEMO VIDEO however, you have the full pictures on the Image Gallery happy reading :)

Project Story Inspiration

I know exactly how many days a year I lose, because I made the app tell me.

Three hours a day is forty-six days. Not forty-six evenings — forty-six entire days, awake, gone. Keep that pace for fifteen more years and it's about 1.9 years of your life. I typed those numbers into the onboarding because I wanted to see them on a screen, in my own hand, and then I had to sit with them.

I'm sixteen. I've spent those years proving to myself that showing up beats talent. I taught myself to read two years before school got round to it, in the back of a car, asking what every motorway sign said until whoever was in front got tired of answering. When I got my first phone I installed Duolingo instead of a single game and started learning English that day. I'm on 294 days. Two years in I clicked a YouTube video that happened to be in English and understood all of it, which genuinely shocked me. Nobody warns you that the day arrives. It just does, because you kept turning up.

So I know the mechanism works. I also know exactly where it breaks.

Every tool built to protect that mechanism is something you can dismiss. A blocker has an "ignore for 15 minutes" button and you will press it. A notification is furniture — it sits in a stack with forty others and you swipe it away without reading it. A streak counter is a number going down, and a number cannot look you in the eye.

There is exactly one thing on a phone you cannot treat as furniture, and that is a ringing call.

And there is exactly one voice you cannot argue with, because every counter-argument is one you have already heard yourself make.

That's the whole product. Stick calls you, and it calls you in your own voice.

What it does

Stick is an agent that phones you to hold you to what you said you wanted. It runs on a schedule and on what your phone is actually doing, and none of it requires you to open the app. You don't use Stick. Stick calls you.

It clones your voice from a promise, not a script. Onboarding is five tapped questions — what you're trying to change, what eats your day, how many hours, how hard you want to be pushed, what time to call. Then the server writes a statement out of those exact answers and puts it on screen for you to read aloud:

This is me. I am going to ship something of my own. I lose about 3 hours a day to TikTok, and I am done pretending that is fine. So when I reach for it instead of doing the work, I want Stick to call me out, and not go easy on me. I am recording this on a good day, so it can be played back to me on a bad one. This is my own voice, and I am asking for it.

That recording does two jobs at once. It's the training audio for the clone, and it's a commitment in the first person, made on a good day, worth playing back on a bad one. Reading a neutral script would have produced the same clone and none of the second thing.

Three calls, and it places them itself.

Morning. It wakes you, asks what the day is for, pushes back if you list eight things, and writes the survivors into a checklist while you talk. Intervention. You open TikTok. A usage threshold fires in a background extension, the escalation ladder decides this one is worth a call, and your own voice says: "It is nine forty-two at night, and you just picked up your phone to scroll." Then it quotes the reason you gave, and gives you one instruction for the next ten minutes. Evening. It goes through what you committed to this morning, refuses a vague "mostly", gets the real reason in one sentence, saves it as a pattern, and asks what tomorrow's one thing is.

It escalates, and it knows when to shut up. Not every slip is worth a phone call. A ladder decides between shielding the app, sending one line of text, calling, and calling hard — with cooldowns so it can't hound you, and quiet hours from 22:00 to 07:00 that it will not cross. That ladder is pure, deterministic code with tests. It is never a question put to the model.

It cannot clone anyone but you. The enrollment endpoint refuses any audio whose transcript is not the text the server itself issued. That's not a policy in a prompt, it's a 400. It is the structural reason this is a tool and not a weapon.

And it never gets to sign anything. Stick books your attention. It doesn't touch your money, your accounts, or anyone else's phone.

How we built it

The whole thing is a native iOS app talking to one small agent backend over a WebSocket.

The phone is native SwiftUI, iOS 17+, Swift 6 strict concurrency, no cross-platform layer. That was a decision I reversed into: the three things Stick depends on — Screen Time, a full-screen call, and a microphone that doesn't feed back — are all places where a wrapper would have stood between me and the bug.

The call screen is drawn by the app, deliberately. Since iOS 13, a CallKit call has to arrive from a real VoIP push, and Apple rejects apps that fake one. So Stick doesn't pretend to be the phone app — it presents its own full-screen incoming call, and it's honest about being an app.

Screen Time does the watching, and I never learn what you watch. FamilyControls gives Apple's own picker; what comes back is an opaque token. DeviceActivity fires thresholds at 1, 5, 15, 30 and 45 minutes inside a background extension, and ManagedSettings puts a shield on the app with your own words printed on it. Three separate app extensions, no network in the shield process, and neither the app nor my server ever learns which apps you chose.

The backend is Hono + Drizzle + Postgres, and the interesting part is the call session. Gemini streams tokens, a SentenceStream cuts them into speakable sentences as they arrive, and each finished sentence goes straight to Fish Audio and comes back down the socket as an audio frame. The first words play about a second in, instead of after the whole turn. Waiting for a complete paragraph before speaking is what makes a voice agent feel like a kiosk.

Gemini 3.7 Flash on Vertex AI does the talking and calls four tools while it talks: add_task, complete_task, set_intention, save_memory. Every tool call is logged next to the sentence that produced it.

The rules that protect the user live in code, upstream of the model. Quiet hours, cooldowns, the ladder, the consent check on enrollment — all decided before the model is constructed. The model chooses words. It never decides whether it is allowed to ring you.

The microphone is on-device. SFSpeechRecognizer with requiresOnDeviceRecognition wherever the phone supports it, so your half of the call is transcribed on the handset.

I wrote this with an AI, in one long night. What I contributed was the shape — where data goes, what runs on a timer, what is allowed to fail and what is not — and the precision of what I asked for. My debugging method is crude and beats everything clever I've tried: open a completely new conversation, hand it the code cold, ask it to find every bug. There are always more.

Challenges we ran into

Stick spent an entire call talking to himself. The first turn was always fine. Then he'd answer his own last sentence, and answer that, forever. The echo guard was measuring its window from the moment he started speaking rather than the moment he stopped — so any sentence longer than 0.8 seconds left the guard already expired exactly when it was needed. The recogniser commits a trailing turn a full 1.5 seconds after the audio ends, so the partials were filtered and the one thing that actually reached the server never was. I fixed the timing, then stopped trusting the heuristic and put a structural rule behind it: while his audio is playing, a transcript only becomes a user turn if it already looked enough like a person interrupting to have stopped him. Word overlap can be fooled by a mangled transcript. That cannot.

A wake-up call that opened by pointing out it was past midnight. Perfectly true, completely useless. The prompt was reading the wall clock, which is only right by coincidence — when the scheduler happens to fire on the minute. A wake-up call happens at wake-up time; that's what the slot means. Morning and evening calls are now framed at their scheduled hour, and interventions keep the real clock, because an intervention is about this minute and nothing else.

Stick changed sex mid-sentence. Man, then woman, then man. I was hunting a bug in my own audio pipeline for an hour before I sent the same text three times and got three different voices back. Fish Audio, without a pinned reference_id, hands you a random speaker. My code was flawless; my assumption was that an API called with identical input returns comparable output.

Vertex AI's SSE stream uses \r\n\r\n, not \n\n. My parser never emitted a token and Stick was silent, with a 200 OK and a healthy-looking stream.

Gemini 3 charges reasoning against maxOutputTokens even at thinkingBudget: 0. Every summary came back truncated after five tokens with MAX_TOKENS. And it requires thoughtSignature echoed back verbatim on function-call parts, or every tool round-trip 400s.

Three MainActor.assumeIsolated traps, all SIGTRAP. A WebSocket handler on a background queue, a permission callback on an XPC queue, an audio tap on the realtime thread. Asking for a permission must not be isolated to the actor that wants the answer.

I handed the phone ws://localhost. Calls connected to nothing and closed instantly.

Stick heard himself through the speaker. Setting the audio session to .voiceChat is not enough — AVAudioEngine still taps the raw microphone. It needs setVoiceProcessingEnabled(true) on the input node, before the engine starts.

And the one that actually hurts. Before this, I submitted a hackathon project at 23:01. One minute after the deadline. I went to bed and didn't want to speak to anyone. I have now lost twice for being late — once for my family, once for myself. I'm writing this section with the clock visible on the other screen.

Accomplishments that we're proud of

The first time it rang my actual iPhone, unprompted, from a schedule I'd set hours earlier, and started talking — I didn't say anything. That's not normal for me. In maths class, when a proof lands, I shout "C'est du génie !" out loud, and my teacher finds it very funny and I've never been able to stop. I went completely silent instead.

The rest, measured rather than claimed: a voice clone built in 4.6 seconds from a single 30-second statement. Full conversational calls on Gemini 3.7 Flash with tool calls and ten-plus audio clips per call. The scheduler firing a morning call within 7 seconds of its slot. The ladder producing hard_call and then correctly degrading under cooldown. Screen Time authorization, picker and shield arming on device. 28 tests, typecheck clean, Release build clean.

Two things I'd rather say than let you infer. Nobody outside my own household has used this. And I have never heard the audio myself — I drove the simulator headlessly, with no host microphone. That blind spot is precisely what hid the changing-voice bug for an hour, and I'd rather write it down than have a judge find it.

What we learned

Take the facts out of the prompt. Anything important pasted into an instruction is something the model is free to skim, and when it skims it does not fail loudly — it produces a confident sentence built on a fact it never read. The clock bug was exactly this shape: a true fact, in the prompt, used to say something useless.

A rule you can talk a model out of isn't a rule. It's a suggestion with good branding. Quiet hours and cooldowns are code that runs before the agent exists. If the consequence is paid by the user rather than by you, it belongs upstream of the model where no wording reaches it.

Consent has to be structural, not editorial. A voice-cloning app is one missing check away from being a tool for cloning someone else's voice. The check isn't a sentence in a prompt. It's the server refusing audio whose transcript isn't the text it issued.

Green proves nothing about the thing you care about. A call that connects, streams, and closes cleanly looks identical in the logs whether the human could hear it or not.

Ship the demo path first. I removed a beautiful spoken onboarding because it depended on speech recognition, a microphone the OS was willing to hand over, and a model round trip — and any one of those failing left the user with no way forward at all. Five taps cannot fail.

What's next for Stick

Deploy the backend to Cloud Run and move the store to Cloud SQL — right now it runs on an embedded Postgres beside the API, which is honest for a night's work and wrong for anyone else's phone.

Then, in order: put the escalation intake on Pub/Sub so a threshold event from a background extension survives a server restart. Move enrollment audio behind signed URLs with an expiry, because a voice sample is biometric data and should not outlive its purpose. Ship the real push path, so a call reaches a locked phone rather than a foregrounded one. Add a weekly call that reads your own statement back to you.

And finally, making UGC Content to sell stick and build a profitable business!

Built With

Share this project:

Updates

Submission history